use std::fmt;
use std::str::FromStr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use crate::provenance::ProvenanceError;
pub const BLOCKTRAIL_TYPE: &str = "Blocktrail";
pub const BLOCKTRAIL_VERSION: &str = "0.0.3";
pub const GITMARK_PROFILE: &str = "gitmark";
pub const DEFAULT_PROFILE: &str = "monochrome";
pub const GITMARK_NETWORK: &str = "gitmark";
#[must_use]
pub fn txo_chain_for_network(network: &str) -> String {
match network {
"testnet4" => "tbtc4".into(),
"testnet" | "testnet3" => "tbtc3".into(),
"bitcoin" | "btc" => "mainnet".into(),
other => other.into(),
}
}
#[must_use]
pub fn network_for_txo_chain(chain: &str) -> String {
match chain {
"tbtc4" => "testnet4".into(),
"tbtc3" | "tbtc" => "testnet".into(),
"btc" | "bitcoin" => "mainnet".into(),
other => other.into(),
}
}
#[must_use]
pub fn is_gitmark_commit(commit: &str) -> bool {
matches!(commit.len(), 40 | 64)
&& commit
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TxoUriError(pub String);
impl fmt::Display for TxoUriError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "invalid TXO URI: {}", self.0)
}
}
impl std::error::Error for TxoUriError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlocktrailTxo {
pub chain: Option<String>,
pub txid: String,
pub vout: u32,
pub amount: Option<u64>,
pub commit: Option<String>,
pub pubkey: Option<String>,
pub blockheight: Option<u64>,
}
impl BlocktrailTxo {
#[must_use]
pub fn new(chain: impl Into<String>, txid: impl Into<String>, vout: u32) -> Self {
Self {
chain: Some(chain.into()),
txid: txid.into(),
vout,
amount: None,
commit: None,
pubkey: None,
blockheight: None,
}
}
#[must_use]
pub fn with_amount(mut self, amount: u64) -> Self {
self.amount = Some(amount);
self
}
#[must_use]
pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
self.commit = Some(commit.into());
self
}
#[must_use]
pub fn outpoint(&self) -> String {
format!("{}:{}", self.txid, self.vout)
}
#[must_use]
pub fn is_legacy(&self) -> bool {
self.chain.is_none()
}
#[must_use]
pub fn to_uri(&self) -> Option<String> {
let chain = self.chain.as_deref()?;
let mut uri = format!("txo:{chain}:{}:{}", self.txid, self.vout);
let mut params = Vec::new();
if let Some(a) = self.amount {
params.push(format!("amount={a}"));
}
if let Some(p) = self.pubkey.as_deref().filter(|p| !p.is_empty()) {
params.push(format!("pubkey={p}"));
}
if let Some(c) = self.commit.as_deref().filter(|c| !c.is_empty()) {
params.push(format!("commit={c}"));
}
if !params.is_empty() {
uri.push('?');
uri.push_str(¶ms.join("&"));
}
Some(uri)
}
pub fn parse_uri(uri: &str) -> Result<Self, TxoUriError> {
let body = uri
.strip_prefix("txo:")
.ok_or_else(|| TxoUriError("must start with txo:".into()))?;
let (path, query) = match body.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (body, None),
};
let mut parts = path.split(':');
let (Some(chain), Some(txid), Some(vout), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err(TxoUriError("expected txo:<chain>:<txid>:<vout>".into()));
};
if chain.is_empty() || !chain.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Err(TxoUriError(format!("bad chain {chain:?}")));
}
if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(TxoUriError("the txid must be 64 hex characters".into()));
}
if vout.is_empty() || !vout.bytes().all(|b| b.is_ascii_digit()) {
return Err(TxoUriError(format!("bad vout {vout:?}")));
}
let vout: u32 = vout
.parse()
.map_err(|_| TxoUriError(format!("vout {vout} out of range")))?;
let mut txo = Self::new(chain, txid, vout);
for pair in query.unwrap_or_default().split('&') {
let Some((key, raw)) = pair.split_once('=') else {
continue;
};
let value = percent_decode(raw)?;
if value.is_empty() {
continue;
}
match key {
"amount" => {
txo.amount = Some(
value
.parse()
.map_err(|_| TxoUriError(format!("bad amount {value:?}")))?,
);
}
"commit" => txo.commit = Some(value),
"pubkey" => txo.pubkey = Some(value),
_ => {}
}
}
Ok(txo)
}
fn from_legacy(outpoint: &str, blockheight: Option<u64>) -> Result<Self, TxoUriError> {
let (txid, vout) = outpoint
.rsplit_once(':')
.ok_or_else(|| TxoUriError(format!("outpoint {outpoint:?} is not <txid>:<vout>")))?;
let vout = vout
.parse()
.map_err(|_| TxoUriError(format!("bad vout in outpoint {outpoint:?}")))?;
Ok(Self {
chain: None,
txid: txid.to_string(),
vout,
amount: None,
commit: None,
pubkey: None,
blockheight,
})
}
}
fn percent_decode(raw: &str) -> Result<String, TxoUriError> {
if !raw.contains('%') {
return Ok(raw.to_string());
}
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
let hex = raw
.get(i + 1..i + 3)
.and_then(|h| u8::from_str_radix(h, 16).ok())
.ok_or_else(|| TxoUriError(format!("bad percent escape in {raw:?}")))?;
out.push(hex);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).map_err(|_| TxoUriError(format!("{raw:?} is not UTF-8")))
}
impl FromStr for BlocktrailTxo {
type Err = TxoUriError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse_uri(s)
}
}
impl fmt::Display for BlocktrailTxo {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.to_uri() {
Some(uri) => f.write_str(&uri),
None => f.write_str(&self.outpoint()),
}
}
}
#[derive(Serialize)]
struct LegacyTxoOut<'a> {
outpoint: String,
#[serde(skip_serializing_if = "Option::is_none")]
blockheight: &'a Option<u64>,
}
#[derive(Deserialize)]
struct LegacyTxoIn {
outpoint: String,
#[serde(default)]
blockheight: Option<u64>,
}
impl Serialize for BlocktrailTxo {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
match self.to_uri() {
Some(uri) => s.serialize_str(&uri),
None => LegacyTxoOut {
outpoint: self.outpoint(),
blockheight: &self.blockheight,
}
.serialize(s),
}
}
}
impl<'de> Deserialize<'de> for BlocktrailTxo {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
match Value::deserialize(d)? {
Value::String(uri) => Self::parse_uri(&uri).map_err(D::Error::custom),
obj @ Value::Object(_) => {
let legacy: LegacyTxoIn = serde_json::from_value(obj).map_err(D::Error::custom)?;
Self::from_legacy(&legacy.outpoint, legacy.blockheight).map_err(D::Error::custom)
}
other => Err(D::Error::custom(format!(
"a txo entry is a TXO URI or {{outpoint, blockheight}}, not {other}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Blocktrail {
#[serde(rename = "@id", default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
#[serde(rename = "@type")]
pub type_: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
#[serde(
rename = "pubkeyBase",
default,
skip_serializing_if = "Option::is_none"
)]
pub pubkey_base: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub chain: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub genesis: Option<String>,
#[serde(default)]
pub states: Vec<Value>,
#[serde(default)]
pub txo: Vec<BlocktrailTxo>,
}
impl Blocktrail {
#[must_use]
pub fn gitmark(
pubkey_base: impl Into<String>,
chain: impl Into<String>,
commits: Vec<String>,
txo: Vec<BlocktrailTxo>,
) -> Self {
Self {
id: None,
type_: BLOCKTRAIL_TYPE.to_string(),
version: Some(BLOCKTRAIL_VERSION.to_string()),
profile: Some(GITMARK_PROFILE.to_string()),
pubkey_base: Some(pubkey_base.into()),
chain: Some(chain.into()),
genesis: None,
states: commits.into_iter().map(Value::String).collect(),
txo,
}
}
#[must_use]
pub fn profile_name(&self) -> String {
self.profile
.as_deref()
.filter(|p| !p.is_empty())
.unwrap_or(DEFAULT_PROFILE)
.to_lowercase()
}
#[must_use]
pub fn is_gitmark(&self) -> bool {
self.profile_name() == GITMARK_PROFILE
}
#[must_use]
pub fn is_legacy(&self) -> bool {
self.id.is_some() || self.genesis.is_some() || self.txo.iter().any(|t| t.is_legacy())
}
pub fn state_strings(&self) -> Result<Vec<String>, String> {
let from_txo: Vec<Value>;
let states: &[Value] = if self.states.is_empty() && self.is_gitmark() {
from_txo = self
.txo
.iter()
.map(|t| t.commit.clone().map_or(Value::Null, Value::String))
.collect();
&from_txo
} else {
&self.states
};
if states.len() != self.txo.len() {
return Err(format!(
"the trail has {} marks and {} states",
self.txo.len(),
states.len()
));
}
states
.iter()
.enumerate()
.map(|(i, s)| match s {
Value::Null => Err(format!("mark {i} has no state")),
other => Ok(crate::mrc20::bt_state_string(other)),
})
.collect()
}
pub fn to_blocktrails_json(&self) -> Result<String, ProvenanceError> {
serde_json::to_string_pretty(self)
.map_err(|e| ProvenanceError::Store(format!("blocktrails.json serialise: {e}")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MarkStatus {
Verified,
Confirmed,
WrongKey,
AmountMismatch,
Unconfirmed,
NotFound,
BrokenLink,
}
impl MarkStatus {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Verified => "verified",
Self::Confirmed => "confirmed",
Self::WrongKey => "wrong key",
Self::AmountMismatch => "amount mismatch",
Self::Unconfirmed => "unconfirmed",
Self::NotFound => "not found",
Self::BrokenLink => "broken link",
}
}
#[must_use]
pub fn is_ok(self) -> bool {
matches!(self, Self::Verified | Self::Confirmed)
}
#[must_use]
pub fn is_pending(self) -> bool {
matches!(self, Self::Unconfirmed)
}
#[must_use]
pub fn is_bad(self) -> bool {
!self.is_ok() && !self.is_pending()
}
}
impl fmt::Display for MarkStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum TrailVerdict {
Verified,
Confirmed,
Partial,
}
impl TrailVerdict {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Verified => "verified",
Self::Confirmed => "confirmed",
Self::Partial => "partial",
}
}
}
impl fmt::Display for TrailVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MarkReport {
pub index: usize,
pub txid: String,
pub vout: u32,
pub status: MarkStatus,
pub confirmed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_height: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub amount: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commits: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub links_to_prev: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_output: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TrailReport {
pub marks: Vec<MarkReport>,
pub commitments_checked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub walk_error: Option<String>,
pub verdict: TrailVerdict,
}
impl TrailReport {
#[cfg_attr(not(feature = "mrc20"), allow(dead_code))]
fn new(marks: Vec<MarkReport>, walk_error: Option<String>) -> Self {
let commitments_checked = walk_error.is_none();
let n = marks.len();
let ok = marks.iter().filter(|m| m.status.is_ok()).count();
let commit_ok = marks.iter().filter(|m| m.commits == Some(true)).count();
let verdict = if n > 0 && ok == n && (!commitments_checked || commit_ok == n) {
if commitments_checked {
TrailVerdict::Verified
} else {
TrailVerdict::Confirmed
}
} else {
TrailVerdict::Partial
};
Self {
marks,
commitments_checked,
walk_error,
verdict,
}
}
#[must_use]
pub fn ok_count(&self) -> usize {
self.marks.iter().filter(|m| m.status.is_ok()).count()
}
#[must_use]
pub fn pending_count(&self) -> usize {
self.marks.iter().filter(|m| m.status.is_pending()).count()
}
#[must_use]
pub fn bad_count(&self) -> usize {
self.marks.iter().filter(|m| m.status.is_bad()).count()
}
#[must_use]
pub fn commit_ok_count(&self) -> usize {
self.marks
.iter()
.filter(|m| m.commits == Some(true))
.count()
}
#[must_use]
pub fn commit_bad_count(&self) -> usize {
self.marks
.iter()
.filter(|m| m.commits == Some(false))
.count()
}
#[must_use]
pub fn is_verified(&self) -> bool {
self.verdict == TrailVerdict::Verified
}
#[must_use]
pub fn is_intact(&self) -> bool {
self.commitments_checked
&& !self.marks.is_empty()
&& self
.marks
.iter()
.all(|m| m.commits == Some(true) && !m.status.is_bad())
}
#[must_use]
pub fn first_failure(&self) -> Option<&MarkReport> {
self.marks.iter().find(|m| !m.status.is_ok())
}
}
#[cfg_attr(not(feature = "mrc20"), allow(dead_code))]
fn mark_status(
found: bool,
confirmed: bool,
amount_ok: bool,
commits: Option<bool>,
links_to_prev: Option<bool>,
) -> MarkStatus {
if !found {
MarkStatus::NotFound
} else if commits == Some(false) {
MarkStatus::WrongKey
} else if links_to_prev == Some(false) {
MarkStatus::BrokenLink
} else if !confirmed {
MarkStatus::Unconfirmed
} else if !amount_ok {
MarkStatus::AmountMismatch
} else if commits == Some(true) {
MarkStatus::Verified
} else {
MarkStatus::Confirmed
}
}
#[cfg(feature = "mrc20")]
mod walk {
use super::*;
use crate::mrc20::{bt_base_point, bt_trail_outputs, MempoolLookup, TxInfo};
use crate::payments::PaymentError;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaseReading {
FullPoint,
BareX,
Identifier,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrailWalk {
pub expected: Vec<[u8; 32]>,
pub profile: String,
pub base: BaseReading,
}
pub fn walk_blocktrail(trail: &Blocktrail) -> Result<TrailWalk, String> {
let profile = trail.profile_name();
let state_strings = trail.state_strings()?;
let raw = trail.pubkey_base.as_deref().unwrap_or("").to_lowercase();
let is_hex = |s: &str| s.bytes().all(|b| b.is_ascii_hexdigit());
let (base_hex, base) = if raw.len() == 66
&& (raw.starts_with("02") || raw.starts_with("03"))
&& is_hex(&raw)
{
(raw, BaseReading::FullPoint)
} else if raw.len() == 64 && is_hex(&raw) {
(format!("02{raw}"), BaseReading::BareX)
} else {
match bt_base_point(&raw) {
Ok(point) if !raw.is_empty() => (hex::encode(point), BaseReading::Identifier),
_ => {
return Err("the trail names no base key (pubkeyBase): nothing to \
recompute from"
.into())
}
}
};
let expected = bt_trail_outputs(&base_hex, &state_strings).map_err(|e| e.to_string())?;
Ok(TrailWalk {
expected,
profile,
base,
})
}
fn output_spk(tx: &TxInfo, vout: u32) -> Option<(&str, u64)> {
tx.vout
.get(vout as usize)
.map(|o| (o.scriptpubkey.as_deref().unwrap_or(""), o.value))
}
fn commits_to(spk: &str, expected: &[u8; 32]) -> Option<bool> {
(!spk.is_empty())
.then(|| spk.eq_ignore_ascii_case(&format!("5120{}", hex::encode(expected))))
}
pub async fn verify_blocktrail(trail: &Blocktrail, mempool: &dyn MempoolLookup) -> TrailReport {
let (walked, walk_error) = match walk_blocktrail(trail) {
Ok(w) => (Some(w), None),
Err(e) => (None, Some(e)),
};
let walk_error = if trail.txo.is_empty() {
Some(walk_error.unwrap_or_else(|| "the trail has no marks".into()))
} else {
walk_error
};
let mut marks = Vec::with_capacity(trail.txo.len());
for (i, t) in trail.txo.iter().enumerate() {
let expected = walked.as_ref().map(|w| w.expected[i]);
let mut report = MarkReport {
index: i,
txid: t.txid.clone(),
vout: t.vout,
status: MarkStatus::NotFound,
confirmed: false,
block_height: None,
value: None,
amount: t.amount,
commits: None,
links_to_prev: None,
expected_output: expected.map(hex::encode),
};
if let Ok(tx) = mempool.tx(&t.txid).await {
let out = output_spk(&tx, t.vout);
let amount_ok = t.amount.is_none() || out.map(|(_, v)| v) == t.amount;
let links = (i > 0).then(|| {
let prev = &trail.txo[i - 1].txid;
tx.vin.iter().any(|v| &v.txid == prev)
});
let commits = match (expected, out) {
(Some(want), Some((spk, _))) => commits_to(spk, &want),
_ => None,
};
report.status = mark_status(true, tx.confirmed, amount_ok, commits, links);
report.confirmed = tx.confirmed;
report.block_height = tx.block_height;
report.value = out.map(|(_, v)| v);
report.commits = commits;
report.links_to_prev = links;
}
marks.push(report);
}
TrailReport::new(marks, walk_error)
}
pub async fn verify_anchor_chain(
pubkey_base: &str,
state_strings: &[String],
head_txid: &str,
head_vout: u32,
mempool: &dyn MempoolLookup,
) -> Result<TrailReport, PaymentError> {
if state_strings.is_empty() {
return Err(PaymentError::InvalidState(
"an anchor chain needs at least one state".into(),
));
}
let expected = bt_trail_outputs(pubkey_base, state_strings)?;
let k = expected.len();
let mut marks: Vec<Option<MarkReport>> = vec![None; k];
let mut cursor = Some((head_txid.to_string(), head_vout));
let mut cached: Option<TxInfo> = None;
for i in (0..k).rev() {
let blank = |txid: String, vout: u32| MarkReport {
index: i,
txid,
vout,
status: MarkStatus::NotFound,
confirmed: false,
block_height: None,
value: None,
amount: None,
commits: None,
links_to_prev: None,
expected_output: Some(hex::encode(expected[i])),
};
let Some((txid, vout)) = cursor.take() else {
marks[i] = Some(blank(String::new(), 0));
continue;
};
let tx = match cached.take() {
Some(t) if t.txid == txid => Ok(t),
_ => mempool.tx(&txid).await,
};
let Ok(tx) = tx else {
marks[i] = Some(blank(txid, vout));
continue;
};
let out = output_spk(&tx, vout);
let commits = out.and_then(|(spk, _)| commits_to(spk, &expected[i]));
let links = if i == 0 {
None
} else if tx.vin.is_empty() {
Some(false)
} else {
let mut chosen = None;
if tx.vin.len() > 1 {
for v in &tx.vin {
if let Ok(prev) = mempool.tx(&v.txid).await {
let hit = output_spk(&prev, v.vout)
.and_then(|(spk, _)| commits_to(spk, &expected[i - 1]))
== Some(true);
if hit {
chosen = Some((v.txid.clone(), v.vout));
cached = Some(prev);
break;
}
}
}
}
let (ptxid, pvout) =
chosen.unwrap_or_else(|| (tx.vin[0].txid.clone(), tx.vin[0].vout));
cursor = Some((ptxid, pvout));
Some(true)
};
let mut report = blank(txid, vout);
report.status = mark_status(true, tx.confirmed, true, commits, links);
report.confirmed = tx.confirmed;
report.block_height = tx.block_height;
report.value = out.map(|(_, v)| v);
report.commits = commits;
report.links_to_prev = links;
marks[i] = Some(report);
}
Ok(TrailReport::new(
marks
.into_iter()
.map(|m| m.expect("every mark visited"))
.collect(),
None,
))
}
}
#[cfg(feature = "mrc20")]
pub use walk::{verify_anchor_chain, verify_blocktrail, walk_blocktrail, BaseReading, TrailWalk};
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const TXID: &str = "51d87101b7cbb01cc5a68785bf3141ec6fd00894d71ab1168d4daa20420eeacf";
const COMMIT: &str = "9adc596cfd1100333393a12f2f41b2d820f16d0b";
#[test]
fn txo_uri_round_trips_in_git_mark_order() {
let uri = format!(
"txo:tbtc4:{TXID}:0?amount=999700&pubkey={}&commit={COMMIT}",
"ab".repeat(32)
);
let t: BlocktrailTxo = uri.parse().unwrap();
assert_eq!(t.pubkey.as_deref(), Some("ab".repeat(32).as_str()));
assert_eq!(t.to_string(), uri);
let reordered: BlocktrailTxo = format!("txo:gitmark:{TXID}:3?commit={COMMIT}&amount=5")
.parse()
.unwrap();
assert_eq!(
reordered.to_string(),
format!("txo:gitmark:{TXID}:3?amount=5&commit={COMMIT}")
);
assert_eq!(
BlocktrailTxo::parse_uri(&format!("txo:mainnet:{TXID}:1"))
.unwrap()
.to_string(),
format!("txo:mainnet:{TXID}:1")
);
}
#[test]
fn txo_uri_rejects_malformed() {
for bad in [
format!("btc:mainnet:{TXID}:0"),
format!("txo:{TXID}:0"),
format!("txo:tbtc4:{}:0", &TXID[..60]),
format!("txo:tbtc4:{TXID}:-1"),
format!("txo:tbtc4:{TXID}:x"),
format!("txo:tb-tc:{TXID}:0"),
format!("txo:tbtc4:{TXID}:0:9"),
format!("txo:tbtc4:{TXID}:0?amount=lots"),
format!("txo:tbtc4:{TXID}:0?commit=%zz"),
] {
assert!(BlocktrailTxo::parse_uri(&bad).is_err(), "{bad} accepted");
}
}
#[test]
fn txo_uri_empty_values_are_absent_and_percent_decoded() {
let t =
BlocktrailTxo::parse_uri(&format!("txo:tbtc4:{TXID}:0?amount=&commit=%61b&x=1&flag"))
.unwrap();
assert_eq!(t.amount, None);
assert_eq!(t.commit.as_deref(), Some("ab"));
}
#[test]
fn legacy_entry_round_trips_as_written() {
let v = json!({"outpoint": format!("{TXID}:2"), "blockheight": 840000});
let t: BlocktrailTxo = serde_json::from_value(v.clone()).unwrap();
assert!(t.is_legacy());
assert_eq!(
(t.txid.as_str(), t.vout, t.blockheight),
(TXID, 2, Some(840_000))
);
assert_eq!(serde_json::to_value(&t).unwrap(), v);
let no_height = json!({"outpoint": "t0:0"});
let t: BlocktrailTxo = serde_json::from_value(no_height.clone()).unwrap();
assert_eq!(serde_json::to_value(&t).unwrap(), no_height);
assert!(serde_json::from_value::<BlocktrailTxo>(json!(7)).is_err());
assert!(serde_json::from_value::<BlocktrailTxo>(json!({"outpoint": "nocolon"})).is_err());
}
#[test]
fn state_strings_follow_blocktrails_verify() {
let txo = BlocktrailTxo::new("tbtc4", TXID, 0).with_commit(COMMIT);
let mut t = Blocktrail::gitmark("02aa", "tbtc4", vec![], vec![txo.clone()]);
assert_eq!(t.state_strings().unwrap(), vec![COMMIT.to_string()]);
t.profile = Some("mono.mrc20.v0.1".into());
t.states = vec![json!({"b": 1, "a": 2})];
assert_eq!(
t.state_strings().unwrap(),
vec![r#"{"a":2,"b":1}"#.to_string()]
);
t.states = vec![];
assert_eq!(
t.state_strings().unwrap_err(),
"the trail has 1 marks and 0 states"
);
t.states = vec![Value::Null];
assert_eq!(t.state_strings().unwrap_err(), "mark 0 has no state");
t.profile = None;
assert_eq!(t.profile_name(), DEFAULT_PROFILE);
t.profile = Some("GitMark".into());
assert!(t.is_gitmark());
}
#[test]
fn verdicts_follow_blocktrails_verify() {
let mark = |status, commits| MarkReport {
index: 0,
txid: TXID.into(),
vout: 0,
status,
confirmed: true,
block_height: None,
value: None,
amount: None,
commits,
links_to_prev: None,
expected_output: None,
};
let r = TrailReport::new(vec![mark(MarkStatus::Verified, Some(true))], None);
assert!(r.is_verified() && r.is_intact());
let r = TrailReport::new(
vec![mark(MarkStatus::Confirmed, None)],
Some("no base".into()),
);
assert_eq!(r.verdict, TrailVerdict::Confirmed);
assert!(!r.is_intact());
let r = TrailReport::new(vec![mark(MarkStatus::Confirmed, None)], None);
assert_eq!(r.verdict, TrailVerdict::Partial);
let r = TrailReport::new(vec![mark(MarkStatus::Unconfirmed, Some(true))], None);
assert_eq!(r.verdict, TrailVerdict::Partial);
assert!(r.is_intact(), "a pending head is intact");
assert_eq!(
TrailReport::new(vec![], None).verdict,
TrailVerdict::Partial
);
assert_eq!(
mark_status(true, true, true, Some(true), Some(false)),
MarkStatus::BrokenLink
);
assert_eq!(
mark_status(true, false, false, Some(false), None),
MarkStatus::WrongKey
);
}
#[test]
fn chain_names_map_both_ways() {
for (net, chain) in [
("testnet4", "tbtc4"),
("mainnet", "mainnet"),
("signet", "signet"),
("regtest", "regtest"),
("gitmark", "gitmark"),
] {
assert_eq!(txo_chain_for_network(net), chain);
assert_eq!(network_for_txo_chain(chain), net);
}
assert_eq!(network_for_txo_chain("tbtc3"), "testnet");
assert!(is_gitmark_commit(&"a".repeat(64)));
assert!(!is_gitmark_commit(&"g".repeat(40)));
}
}