Skip to main content

solid_pod_rs/
blocktrail.rs

1//! Blocktrails trails as a verifier reads them.
2//!
3//! A *trail* is an ordered list of states, each committed to by one *mark*: a
4//! Bitcoin output whose key is the trail's base key tweaked by every state so
5//! far (Blocktrails Core, "the rule as it is", blocktrails/spec ef54a08). The
6//! marks spend one another in order, so the trail is *anchored*: each state is
7//! timestamped by the block that confirms its mark, and nobody can rewrite the
8//! history without spending a mark again.
9//!
10//! This module holds three things:
11//!
12//! - [`Blocktrail`]: the `blocktrails.json` shape, as the git-mark profile
13//!   (blocktrails/spec, profiles/gitmark §5.2) and blocktrails/git-mark
14//!   b852d7d `trail()` write it: `@type`, `version`, `profile`, `pubkeyBase`,
15//!   `chain`, `states`, and `txo`, a list of TXO URIs. It also reads the
16//!   earlier shape this crate wrote (`@id`, `genesis` and `txo` entries of the
17//!   form `{outpoint, blockheight}`), and writes such entries back unchanged,
18//!   so no file already on disk is rewritten.
19//! - [`BlocktrailTxo`]: one TXO URI,
20//!   `txo:<chain>:<txid>:<vout>?amount=<sats>&commit=<hash>[&pubkey=<x>]`,
21//!   parsed and formatted as blocktrails/git-mark `parseTxoUri` /
22//!   `formatTxoUri` do.
23//! - With feature `mrc20`, the per-link verification blocktrails/verify
24//!   043e7af runs: every mark's output key is recomputed from the base key and
25//!   the states (`walk_blocktrail`); then each mark's transaction must
26//!   exist, carry the recomputed key, be confirmed, match its recorded amount
27//!   and spend the previous mark (`verify_blocktrail`). The verdict is
28//!   *verified* when every commitment was checked and holds, *confirmed* when
29//!   the marks are on-chain but the commitments could not be checked (no base
30//!   key, say), and *partial* otherwise. `verify_anchor_chain` runs the same
31//!   checks from a single head outpoint backwards, for trails that keep only
32//!   their head (the MRC20 trails this crate issues).
33//!
34//! Every link is checked, not the head alone. A head UTXO at the derived
35//! address shows only that someone paid that key; it says nothing about the
36//! marks before it, which could be missing, pay other keys, or not be spent in
37//! order. Only the walk shows that each state was committed, and timestamped,
38//! when its mark was made.
39
40use std::fmt;
41use std::str::FromStr;
42
43use serde::de::Error as _;
44use serde::{Deserialize, Deserializer, Serialize, Serializer};
45use serde_json::Value;
46
47use crate::provenance::ProvenanceError;
48
49/// The `@type` every trail carries.
50pub const BLOCKTRAIL_TYPE: &str = "Blocktrail";
51/// The `version` blocktrails/git-mark b852d7d writes in `blocktrails.json`.
52pub const BLOCKTRAIL_VERSION: &str = "0.0.3";
53/// The git-mark profile: each state is a git commit hash, hashed as its text.
54pub const GITMARK_PROFILE: &str = "gitmark";
55/// The profile a trail with no `profile` is read as (blocktrails/verify).
56pub const DEFAULT_PROFILE: &str = "monochrome";
57/// The network name whose addresses use the `gm` human-readable part: the
58/// sidestr git-mark chain (`NETWORK_CHAIN.gitmark = 'sidestr:gitmark'` in
59/// blocktrails/git-mark b852d7d).
60pub const GITMARK_NETWORK: &str = "gitmark";
61
62// ---------------------------------------------------------------------------
63// Chain names
64// ---------------------------------------------------------------------------
65
66/// The chain token a TXO URI uses for one of this crate's network names.
67///
68/// This crate names networks as the mempool explorers do (`testnet4`,
69/// `mainnet`); TXO URIs and `blocktrails.json` use blocktrails/git-mark's
70/// tokens (`tbtc4`, `mainnet`, `signet`, `regtest`, `gitmark`). A name with no
71/// mapping is returned as it is.
72///
73/// # Examples
74///
75/// ```
76/// use solid_pod_rs::blocktrail::txo_chain_for_network;
77///
78/// assert_eq!(txo_chain_for_network("testnet4"), "tbtc4");
79/// assert_eq!(txo_chain_for_network("mainnet"), "mainnet");
80/// assert_eq!(txo_chain_for_network("gitmark"), "gitmark");
81/// ```
82#[must_use]
83pub fn txo_chain_for_network(network: &str) -> String {
84    match network {
85        "testnet4" => "tbtc4".into(),
86        "testnet" | "testnet3" => "tbtc3".into(),
87        "bitcoin" | "btc" => "mainnet".into(),
88        other => other.into(),
89    }
90}
91
92/// The network name (as `mrc20::bt_address` takes it) for a TXO URI
93/// chain token; the inverse of [`txo_chain_for_network`].
94///
95/// blocktrails/verify reads `tbtc4` as testnet4, `tbtc3` and `tbtc` as the
96/// earlier testnet, and `btc`, `bitcoin` and `mainnet` as mainnet.
97///
98/// # Examples
99///
100/// ```
101/// use solid_pod_rs::blocktrail::network_for_txo_chain;
102///
103/// assert_eq!(network_for_txo_chain("tbtc4"), "testnet4");
104/// assert_eq!(network_for_txo_chain("btc"), "mainnet");
105/// assert_eq!(network_for_txo_chain("gitmark"), "gitmark");
106/// ```
107#[must_use]
108pub fn network_for_txo_chain(chain: &str) -> String {
109    match chain {
110        "tbtc4" => "testnet4".into(),
111        "tbtc3" | "tbtc" => "testnet".into(),
112        "btc" | "bitcoin" => "mainnet".into(),
113        other => other.into(),
114    }
115}
116
117/// Whether `commit` is a git commit hash a git-mark trail accepts as a state:
118/// 40 lowercase hex characters (blocktrails/git-mark `validateCommitHash`),
119/// or 64 for a repository using SHA-256 object names (and for an epoch Merkle
120/// root this crate anchors in a commit's place).
121///
122/// # Examples
123///
124/// ```
125/// use solid_pod_rs::blocktrail::is_gitmark_commit;
126///
127/// assert!(is_gitmark_commit("9adc596cfd1100333393a12f2f41b2d820f16d0b"));
128/// assert!(!is_gitmark_commit("9ADC596CFD1100333393A12F2F41B2D820F16D0B"));
129/// assert!(!is_gitmark_commit("9adc596c"));
130/// ```
131#[must_use]
132pub fn is_gitmark_commit(commit: &str) -> bool {
133    matches!(commit.len(), 40 | 64)
134        && commit
135            .bytes()
136            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
137}
138
139// ---------------------------------------------------------------------------
140// TXO URIs
141// ---------------------------------------------------------------------------
142
143/// A TXO URI that could not be read.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct TxoUriError(pub String);
146
147impl fmt::Display for TxoUriError {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "invalid TXO URI: {}", self.0)
150    }
151}
152
153impl std::error::Error for TxoUriError {}
154
155/// One mark of a trail: the output that commits to a state.
156///
157/// Written as a TXO URI,
158/// `txo:<chain>:<txid>:<vout>?amount=<sats>&commit=<hash>[&pubkey=<x>]`
159/// (blocktrails/spec gitmark profile §3; blocktrails/git-mark b852d7d
160/// `formatTxoUri`, which writes `amount`, then `pubkey`, then `commit`, each
161/// only when it is set). `chain` is `None` only for an entry read from the
162/// earlier `{outpoint, blockheight}` form, which carries no chain and is
163/// written back in that form.
164///
165/// # Examples
166///
167/// ```
168/// use solid_pod_rs::blocktrail::BlocktrailTxo;
169///
170/// let uri = "txo:tbtc4:51d87101b7cbb01cc5a68785bf3141ec6fd00894d71ab1168d4daa20420eeacf:0\
171///            ?amount=999700&commit=9adc596cfd1100333393a12f2f41b2d820f16d0b";
172/// let txo: BlocktrailTxo = uri.parse().unwrap();
173/// assert_eq!(txo.chain.as_deref(), Some("tbtc4"));
174/// assert_eq!(txo.vout, 0);
175/// assert_eq!(txo.amount, Some(999_700));
176/// assert_eq!(txo.to_string(), uri);
177/// ```
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct BlocktrailTxo {
180    /// The chain token (`tbtc4`, `mainnet`, `gitmark`, …); `None` for an entry
181    /// in the earlier `{outpoint, blockheight}` form.
182    pub chain: Option<String>,
183    /// The transaction id, 64 hex characters.
184    pub txid: String,
185    /// The output index within `txid`.
186    pub vout: u32,
187    /// The output's value in sats, when recorded.
188    pub amount: Option<u64>,
189    /// The state this mark commits to, for a git-mark trail its commit hash.
190    pub commit: Option<String>,
191    /// The output's x-only key (64 hex), when recorded. A verifier recomputes
192    /// it from the base key and the states, so it is optional.
193    pub pubkey: Option<String>,
194    /// The confirmation height, kept only by the earlier entry form; a TXO URI
195    /// carries no height (a verifier reads it from the chain).
196    pub blockheight: Option<u64>,
197}
198
199impl BlocktrailTxo {
200    /// A mark at `txid:vout` on `chain`, with nothing else recorded.
201    #[must_use]
202    pub fn new(chain: impl Into<String>, txid: impl Into<String>, vout: u32) -> Self {
203        Self {
204            chain: Some(chain.into()),
205            txid: txid.into(),
206            vout,
207            amount: None,
208            commit: None,
209            pubkey: None,
210            blockheight: None,
211        }
212    }
213
214    /// This mark with its output value recorded.
215    #[must_use]
216    pub fn with_amount(mut self, amount: u64) -> Self {
217        self.amount = Some(amount);
218        self
219    }
220
221    /// This mark with the state it commits to recorded (`commit=`).
222    #[must_use]
223    pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
224        self.commit = Some(commit.into());
225        self
226    }
227
228    /// `<txid>:<vout>`.
229    #[must_use]
230    pub fn outpoint(&self) -> String {
231        format!("{}:{}", self.txid, self.vout)
232    }
233
234    /// Whether this entry was read from the earlier `{outpoint, blockheight}`
235    /// form (it has no chain).
236    #[must_use]
237    pub fn is_legacy(&self) -> bool {
238        self.chain.is_none()
239    }
240
241    /// The TXO URI, or `None` for an entry with no chain.
242    #[must_use]
243    pub fn to_uri(&self) -> Option<String> {
244        let chain = self.chain.as_deref()?;
245        let mut uri = format!("txo:{chain}:{}:{}", self.txid, self.vout);
246        let mut params = Vec::new();
247        if let Some(a) = self.amount {
248            params.push(format!("amount={a}"));
249        }
250        if let Some(p) = self.pubkey.as_deref().filter(|p| !p.is_empty()) {
251            params.push(format!("pubkey={p}"));
252        }
253        if let Some(c) = self.commit.as_deref().filter(|c| !c.is_empty()) {
254            params.push(format!("commit={c}"));
255        }
256        if !params.is_empty() {
257            uri.push('?');
258            uri.push_str(&params.join("&"));
259        }
260        Some(uri)
261    }
262
263    /// Parse a TXO URI.
264    ///
265    /// The chain is letters and digits and the txid 64 hex characters, as
266    /// blocktrails/verify requires; the vout a non-negative integer. Query
267    /// parameters are read as blocktrails/git-mark reads them: `amount` as an
268    /// integer, `commit` and `pubkey` as given (percent-decoded), an empty
269    /// value as absent; any other parameter is ignored.
270    ///
271    /// # Errors
272    ///
273    /// [`TxoUriError`] for a URI that does not start with `txo:`, lacks the
274    /// chain, txid or vout, or has a malformed one of them or of `amount`.
275    pub fn parse_uri(uri: &str) -> Result<Self, TxoUriError> {
276        let body = uri
277            .strip_prefix("txo:")
278            .ok_or_else(|| TxoUriError("must start with txo:".into()))?;
279        let (path, query) = match body.split_once('?') {
280            Some((p, q)) => (p, Some(q)),
281            None => (body, None),
282        };
283        let mut parts = path.split(':');
284        let (Some(chain), Some(txid), Some(vout), None) =
285            (parts.next(), parts.next(), parts.next(), parts.next())
286        else {
287            return Err(TxoUriError("expected txo:<chain>:<txid>:<vout>".into()));
288        };
289        if chain.is_empty() || !chain.bytes().all(|b| b.is_ascii_alphanumeric()) {
290            return Err(TxoUriError(format!("bad chain {chain:?}")));
291        }
292        if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
293            return Err(TxoUriError("the txid must be 64 hex characters".into()));
294        }
295        if vout.is_empty() || !vout.bytes().all(|b| b.is_ascii_digit()) {
296            return Err(TxoUriError(format!("bad vout {vout:?}")));
297        }
298        let vout: u32 = vout
299            .parse()
300            .map_err(|_| TxoUriError(format!("vout {vout} out of range")))?;
301
302        let mut txo = Self::new(chain, txid, vout);
303        for pair in query.unwrap_or_default().split('&') {
304            let Some((key, raw)) = pair.split_once('=') else {
305                continue;
306            };
307            let value = percent_decode(raw)?;
308            if value.is_empty() {
309                continue;
310            }
311            match key {
312                "amount" => {
313                    txo.amount = Some(
314                        value
315                            .parse()
316                            .map_err(|_| TxoUriError(format!("bad amount {value:?}")))?,
317                    );
318                }
319                "commit" => txo.commit = Some(value),
320                "pubkey" => txo.pubkey = Some(value),
321                _ => {}
322            }
323        }
324        Ok(txo)
325    }
326
327    fn from_legacy(outpoint: &str, blockheight: Option<u64>) -> Result<Self, TxoUriError> {
328        let (txid, vout) = outpoint
329            .rsplit_once(':')
330            .ok_or_else(|| TxoUriError(format!("outpoint {outpoint:?} is not <txid>:<vout>")))?;
331        let vout = vout
332            .parse()
333            .map_err(|_| TxoUriError(format!("bad vout in outpoint {outpoint:?}")))?;
334        Ok(Self {
335            chain: None,
336            txid: txid.to_string(),
337            vout,
338            amount: None,
339            commit: None,
340            pubkey: None,
341            blockheight,
342        })
343    }
344}
345
346/// `%XX` decoding of a query value (what `decodeURIComponent` does for the
347/// ASCII values a TXO URI carries).
348fn percent_decode(raw: &str) -> Result<String, TxoUriError> {
349    if !raw.contains('%') {
350        return Ok(raw.to_string());
351    }
352    let bytes = raw.as_bytes();
353    let mut out = Vec::with_capacity(bytes.len());
354    let mut i = 0;
355    while i < bytes.len() {
356        if bytes[i] == b'%' {
357            let hex = raw
358                .get(i + 1..i + 3)
359                .and_then(|h| u8::from_str_radix(h, 16).ok())
360                .ok_or_else(|| TxoUriError(format!("bad percent escape in {raw:?}")))?;
361            out.push(hex);
362            i += 3;
363        } else {
364            out.push(bytes[i]);
365            i += 1;
366        }
367    }
368    String::from_utf8(out).map_err(|_| TxoUriError(format!("{raw:?} is not UTF-8")))
369}
370
371impl FromStr for BlocktrailTxo {
372    type Err = TxoUriError;
373
374    fn from_str(s: &str) -> Result<Self, Self::Err> {
375        Self::parse_uri(s)
376    }
377}
378
379impl fmt::Display for BlocktrailTxo {
380    /// The TXO URI, or `<txid>:<vout>` for an entry with no chain.
381    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382        match self.to_uri() {
383            Some(uri) => f.write_str(&uri),
384            None => f.write_str(&self.outpoint()),
385        }
386    }
387}
388
389#[derive(Serialize)]
390struct LegacyTxoOut<'a> {
391    outpoint: String,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    blockheight: &'a Option<u64>,
394}
395
396#[derive(Deserialize)]
397struct LegacyTxoIn {
398    outpoint: String,
399    #[serde(default)]
400    blockheight: Option<u64>,
401}
402
403impl Serialize for BlocktrailTxo {
404    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
405        match self.to_uri() {
406            Some(uri) => s.serialize_str(&uri),
407            None => LegacyTxoOut {
408                outpoint: self.outpoint(),
409                blockheight: &self.blockheight,
410            }
411            .serialize(s),
412        }
413    }
414}
415
416impl<'de> Deserialize<'de> for BlocktrailTxo {
417    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
418        match Value::deserialize(d)? {
419            Value::String(uri) => Self::parse_uri(&uri).map_err(D::Error::custom),
420            obj @ Value::Object(_) => {
421                let legacy: LegacyTxoIn = serde_json::from_value(obj).map_err(D::Error::custom)?;
422                Self::from_legacy(&legacy.outpoint, legacy.blockheight).map_err(D::Error::custom)
423            }
424            other => Err(D::Error::custom(format!(
425                "a txo entry is a TXO URI or {{outpoint, blockheight}}, not {other}"
426            ))),
427        }
428    }
429}
430
431// ---------------------------------------------------------------------------
432// blocktrails.json
433// ---------------------------------------------------------------------------
434
435/// A trail as `blocktrails.json` carries it: what a verifier reads.
436///
437/// The shape is blocktrails/spec gitmark profile §5.2, the one the live trails
438/// use and blocktrails/git-mark b852d7d `trail()` writes:
439///
440/// ```json
441/// {
442///   "@type": "Blocktrail",
443///   "version": "0.0.3",
444///   "profile": "gitmark",
445///   "pubkeyBase": "02…",
446///   "chain": "tbtc4",
447///   "states": ["<genesis_commit>", "<hash>"],
448///   "txo": ["txo:tbtc4:…:0?amount=…&commit=…", "txo:tbtc4:…"]
449/// }
450/// ```
451///
452/// `pubkeyBase` is the base key as a full compressed point (02/03 + x), so a
453/// verifier has nothing to guess. `states` holds one state per mark, in order:
454/// a string state (a git-mark commit) is hashed as its text, an object state
455/// as its JCS. A git-mark trail may leave `states` empty; the commits are then
456/// read from the TXO URIs.
457///
458/// The earlier shape this crate wrote, with `@id`, `genesis` and `txo`
459/// entries `{outpoint, blockheight}` and no base key, still parses: its
460/// fields are kept in [`id`](Self::id) and [`genesis`](Self::genesis) and its
461/// entries as legacy [`BlocktrailTxo`]s, and it serialises back as it was
462/// read. A trail built here ([`Blocktrail::gitmark`]) has neither field.
463///
464/// # Examples
465///
466/// ```
467/// use solid_pod_rs::blocktrail::{Blocktrail, BlocktrailTxo};
468///
469/// let commit = "9adc596cfd1100333393a12f2f41b2d820f16d0b";
470/// let txid = "51d87101b7cbb01cc5a68785bf3141ec6fd00894d71ab1168d4daa20420eeacf";
471/// let trail = Blocktrail::gitmark(
472///     "0273c7f6cf0f135a63bc95a2e676bcf0a592c8b508fae8697e43f778c74e232b24",
473///     "tbtc4",
474///     vec![commit.to_string()],
475///     vec![BlocktrailTxo::new("tbtc4", txid, 0).with_amount(999_700).with_commit(commit)],
476/// );
477/// let json: serde_json::Value = serde_json::from_str(&trail.to_blocktrails_json().unwrap()).unwrap();
478/// assert_eq!(json["@type"], "Blocktrail");
479/// assert_eq!(json["profile"], "gitmark");
480/// assert_eq!(json["txo"][0], format!("txo:tbtc4:{txid}:0?amount=999700&commit={commit}"));
481/// assert!(json.get("@id").is_none());
482/// ```
483#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
484pub struct Blocktrail {
485    /// The earlier shape's `@id` (`gitmark:<first-commit>:0`); `None` in the
486    /// §5.2 shape.
487    #[serde(rename = "@id", default, skip_serializing_if = "Option::is_none")]
488    pub id: Option<String>,
489    /// Always [`BLOCKTRAIL_TYPE`].
490    #[serde(rename = "@type")]
491    pub type_: String,
492    /// The schema version ([`BLOCKTRAIL_VERSION`] for a trail built here).
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub version: Option<String>,
495    /// The profile (`gitmark`, or a plain profile id); a trail with none is
496    /// read as [`DEFAULT_PROFILE`].
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub profile: Option<String>,
499    /// The base key as a full compressed point (02/03 + x).
500    #[serde(
501        rename = "pubkeyBase",
502        default,
503        skip_serializing_if = "Option::is_none"
504    )]
505    pub pubkey_base: Option<String>,
506    /// The chain token the marks live on (`tbtc4`, `mainnet`, `gitmark`, …).
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub chain: Option<String>,
509    /// The earlier shape's `genesis` (`gitmark:<first-commit>:0`); `None` in
510    /// the §5.2 shape.
511    #[serde(default, skip_serializing_if = "Option::is_none")]
512    pub genesis: Option<String>,
513    /// One state per mark, in order.
514    #[serde(default)]
515    pub states: Vec<Value>,
516    /// The marks, in order.
517    #[serde(default)]
518    pub txo: Vec<BlocktrailTxo>,
519}
520
521impl Blocktrail {
522    /// A git-mark trail in the §5.2 shape: `pubkey_base` as a full compressed
523    /// point, the marks on `chain`, the commits as its states.
524    #[must_use]
525    pub fn gitmark(
526        pubkey_base: impl Into<String>,
527        chain: impl Into<String>,
528        commits: Vec<String>,
529        txo: Vec<BlocktrailTxo>,
530    ) -> Self {
531        Self {
532            id: None,
533            type_: BLOCKTRAIL_TYPE.to_string(),
534            version: Some(BLOCKTRAIL_VERSION.to_string()),
535            profile: Some(GITMARK_PROFILE.to_string()),
536            pubkey_base: Some(pubkey_base.into()),
537            chain: Some(chain.into()),
538            genesis: None,
539            states: commits.into_iter().map(Value::String).collect(),
540            txo,
541        }
542    }
543
544    /// The profile, lower-cased, or [`DEFAULT_PROFILE`] when none is set (as
545    /// blocktrails/verify reads it).
546    #[must_use]
547    pub fn profile_name(&self) -> String {
548        self.profile
549            .as_deref()
550            .filter(|p| !p.is_empty())
551            .unwrap_or(DEFAULT_PROFILE)
552            .to_lowercase()
553    }
554
555    /// Whether this is a git-mark trail.
556    #[must_use]
557    pub fn is_gitmark(&self) -> bool {
558        self.profile_name() == GITMARK_PROFILE
559    }
560
561    /// Whether the trail carries anything of the earlier shape (`@id`,
562    /// `genesis`, or a `{outpoint, blockheight}` entry).
563    #[must_use]
564    pub fn is_legacy(&self) -> bool {
565        self.id.is_some() || self.genesis.is_some() || self.txo.iter().any(|t| t.is_legacy())
566    }
567
568    /// The states, one per mark, as the strings that are hashed: a string
569    /// state as its text, an object state as its JCS
570    /// ([`crate::mrc20::bt_state_string`]). A git-mark trail with no states
571    /// takes them from its TXO URIs' `commit`s, as blocktrails/verify does.
572    ///
573    /// # Errors
574    ///
575    /// The message blocktrails/verify gives: `the trail has M marks and S
576    /// states` when the counts differ, `mark i has no state` for a null state.
577    pub fn state_strings(&self) -> Result<Vec<String>, String> {
578        let from_txo: Vec<Value>;
579        let states: &[Value] = if self.states.is_empty() && self.is_gitmark() {
580            from_txo = self
581                .txo
582                .iter()
583                .map(|t| t.commit.clone().map_or(Value::Null, Value::String))
584                .collect();
585            &from_txo
586        } else {
587            &self.states
588        };
589        if states.len() != self.txo.len() {
590            return Err(format!(
591                "the trail has {} marks and {} states",
592                self.txo.len(),
593                states.len()
594            ));
595        }
596        states
597            .iter()
598            .enumerate()
599            .map(|(i, s)| match s {
600                Value::Null => Err(format!("mark {i} has no state")),
601                other => Ok(crate::mrc20::bt_state_string(other)),
602            })
603            .collect()
604    }
605
606    /// Serialise to `blocktrails.json` text (pretty-printed).
607    ///
608    /// # Errors
609    ///
610    /// [`ProvenanceError::Store`] if serialisation fails.
611    pub fn to_blocktrails_json(&self) -> Result<String, ProvenanceError> {
612        serde_json::to_string_pretty(self)
613            .map_err(|e| ProvenanceError::Store(format!("blocktrails.json serialise: {e}")))
614    }
615}
616
617// ---------------------------------------------------------------------------
618// Verification reports
619// ---------------------------------------------------------------------------
620
621/// What a verifier found for one mark.
622///
623/// [`as_str`](Self::as_str) gives blocktrails/verify's label for each outcome
624/// it has; [`BrokenLink`](Self::BrokenLink) is this crate's addition (see
625/// there).
626#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
627#[serde(rename_all = "kebab-case")]
628pub enum MarkStatus {
629    /// Confirmed, the amount matches, and the output is the recomputed key.
630    Verified,
631    /// Confirmed and the amount matches, but the commitment was not checked
632    /// (the trail could not be walked, or the output could not be read).
633    Confirmed,
634    /// The output is not the key the states derive: the mark does not commit
635    /// to its state.
636    WrongKey,
637    /// Confirmed, but the output does not carry the amount the trail records.
638    AmountMismatch,
639    /// In the mempool, not yet in a block.
640    Unconfirmed,
641    /// The transaction is not on the chain (or could not be reached).
642    NotFound,
643    /// The mark does not spend the previous mark. blocktrails/verify 043e7af
644    /// shows this ("chain link ✗") but still labels such a mark verified; this
645    /// crate counts it as a failure, since its README states that a verified
646    /// trail has its mark chain intact.
647    BrokenLink,
648}
649
650impl MarkStatus {
651    /// blocktrails/verify's label (`verified`, `confirmed`, `wrong key`,
652    /// `amount mismatch`, `unconfirmed`, `not found`), or `broken link`.
653    #[must_use]
654    pub fn as_str(self) -> &'static str {
655        match self {
656            Self::Verified => "verified",
657            Self::Confirmed => "confirmed",
658            Self::WrongKey => "wrong key",
659            Self::AmountMismatch => "amount mismatch",
660            Self::Unconfirmed => "unconfirmed",
661            Self::NotFound => "not found",
662            Self::BrokenLink => "broken link",
663        }
664    }
665
666    /// Counted as good: [`Verified`](Self::Verified) or
667    /// [`Confirmed`](Self::Confirmed).
668    #[must_use]
669    pub fn is_ok(self) -> bool {
670        matches!(self, Self::Verified | Self::Confirmed)
671    }
672
673    /// Counted as pending: [`Unconfirmed`](Self::Unconfirmed).
674    #[must_use]
675    pub fn is_pending(self) -> bool {
676        matches!(self, Self::Unconfirmed)
677    }
678
679    /// Counted as a failure: every other status.
680    #[must_use]
681    pub fn is_bad(self) -> bool {
682        !self.is_ok() && !self.is_pending()
683    }
684}
685
686impl fmt::Display for MarkStatus {
687    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
688        f.write_str(self.as_str())
689    }
690}
691
692/// The summary verdict over a whole trail.
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "kebab-case")]
695pub enum TrailVerdict {
696    /// Every mark is confirmed with its amount, and every output is the key
697    /// its state derives.
698    Verified,
699    /// Every mark is confirmed with its amount, but the commitments were not
700    /// checked (the trail could not be walked): confirmation only.
701    Confirmed,
702    /// Anything less: some mark is pending, failed, or unchecked.
703    Partial,
704}
705
706impl TrailVerdict {
707    /// `verified`, `confirmed` or `partial`, blocktrails/verify's summary pill.
708    #[must_use]
709    pub fn as_str(self) -> &'static str {
710        match self {
711            Self::Verified => "verified",
712            Self::Confirmed => "confirmed",
713            Self::Partial => "partial",
714        }
715    }
716}
717
718impl fmt::Display for TrailVerdict {
719    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
720        f.write_str(self.as_str())
721    }
722}
723
724/// What a verifier found for one mark, with the evidence behind its status.
725#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
726#[serde(rename_all = "camelCase")]
727pub struct MarkReport {
728    /// The mark's position in the trail (0 is the genesis mark).
729    pub index: usize,
730    /// The mark's transaction id; empty when a backward walk could not reach
731    /// this mark.
732    pub txid: String,
733    /// The mark's output index.
734    pub vout: u32,
735    /// The outcome.
736    pub status: MarkStatus,
737    /// Whether the transaction is in a block.
738    pub confirmed: bool,
739    /// The confirming block's height, when known.
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub block_height: Option<u64>,
742    /// The output's value on-chain, when the output was read.
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub value: Option<u64>,
745    /// The amount the trail records for the mark, if any.
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub amount: Option<u64>,
748    /// Whether the output is the recomputed key: `None` when the commitment
749    /// was not checked.
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub commits: Option<bool>,
752    /// Whether the transaction spends the previous mark: `None` for the
753    /// genesis mark and for a mark not found.
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub links_to_prev: Option<bool>,
756    /// The recomputed x-only output key (64 hex), when the trail was walked.
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub expected_output: Option<String>,
759}
760
761/// The result of verifying a trail: one [`MarkReport`] per mark and a
762/// [`TrailVerdict`].
763#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
764#[serde(rename_all = "camelCase")]
765pub struct TrailReport {
766    /// Per-mark results, in trail order.
767    pub marks: Vec<MarkReport>,
768    /// Whether the output keys were recomputed (the trail could be walked).
769    pub commitments_checked: bool,
770    /// Why the commitments could not be checked, when they were not.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub walk_error: Option<String>,
773    /// The summary verdict.
774    pub verdict: TrailVerdict,
775}
776
777impl TrailReport {
778    #[cfg_attr(not(feature = "mrc20"), allow(dead_code))]
779    fn new(marks: Vec<MarkReport>, walk_error: Option<String>) -> Self {
780        let commitments_checked = walk_error.is_none();
781        let n = marks.len();
782        let ok = marks.iter().filter(|m| m.status.is_ok()).count();
783        let commit_ok = marks.iter().filter(|m| m.commits == Some(true)).count();
784        // blocktrails/verify: allOK = okCount === n && (!walked || commitOK === n).
785        // An empty trail verifies nothing, so it is never more than partial.
786        let verdict = if n > 0 && ok == n && (!commitments_checked || commit_ok == n) {
787            if commitments_checked {
788                TrailVerdict::Verified
789            } else {
790                TrailVerdict::Confirmed
791            }
792        } else {
793            TrailVerdict::Partial
794        };
795        Self {
796            marks,
797            commitments_checked,
798            walk_error,
799            verdict,
800        }
801    }
802
803    /// Marks counted as good ([`MarkStatus::is_ok`]).
804    #[must_use]
805    pub fn ok_count(&self) -> usize {
806        self.marks.iter().filter(|m| m.status.is_ok()).count()
807    }
808
809    /// Marks still pending ([`MarkStatus::is_pending`]).
810    #[must_use]
811    pub fn pending_count(&self) -> usize {
812        self.marks.iter().filter(|m| m.status.is_pending()).count()
813    }
814
815    /// Marks that failed ([`MarkStatus::is_bad`]).
816    #[must_use]
817    pub fn bad_count(&self) -> usize {
818        self.marks.iter().filter(|m| m.status.is_bad()).count()
819    }
820
821    /// Outputs found to be the recomputed key.
822    #[must_use]
823    pub fn commit_ok_count(&self) -> usize {
824        self.marks
825            .iter()
826            .filter(|m| m.commits == Some(true))
827            .count()
828    }
829
830    /// Outputs found not to be the recomputed key.
831    #[must_use]
832    pub fn commit_bad_count(&self) -> usize {
833        self.marks
834            .iter()
835            .filter(|m| m.commits == Some(false))
836            .count()
837    }
838
839    /// Whether the verdict is [`TrailVerdict::Verified`].
840    #[must_use]
841    pub fn is_verified(&self) -> bool {
842        self.verdict == TrailVerdict::Verified
843    }
844
845    /// Whether every mark was found, commits to its state and spends its
846    /// predecessor, with no failure anywhere: a [verified](Self::is_verified)
847    /// trail, or one whose newest marks are still in the mempool.
848    #[must_use]
849    pub fn is_intact(&self) -> bool {
850        self.commitments_checked
851            && !self.marks.is_empty()
852            && self
853                .marks
854                .iter()
855                .all(|m| m.commits == Some(true) && !m.status.is_bad())
856    }
857
858    /// The first mark that is not good, if any.
859    #[must_use]
860    pub fn first_failure(&self) -> Option<&MarkReport> {
861        self.marks.iter().find(|m| !m.status.is_ok())
862    }
863}
864
865/// The status blocktrails/verify gives a mark, from what was found (with the
866/// spend link added, see [`MarkStatus::BrokenLink`]).
867#[cfg_attr(not(feature = "mrc20"), allow(dead_code))]
868fn mark_status(
869    found: bool,
870    confirmed: bool,
871    amount_ok: bool,
872    commits: Option<bool>,
873    links_to_prev: Option<bool>,
874) -> MarkStatus {
875    if !found {
876        MarkStatus::NotFound
877    } else if commits == Some(false) {
878        MarkStatus::WrongKey
879    } else if links_to_prev == Some(false) {
880        MarkStatus::BrokenLink
881    } else if !confirmed {
882        MarkStatus::Unconfirmed
883    } else if !amount_ok {
884        MarkStatus::AmountMismatch
885    } else if commits == Some(true) {
886        MarkStatus::Verified
887    } else {
888        MarkStatus::Confirmed
889    }
890}
891
892// ---------------------------------------------------------------------------
893// The walk and the per-link verification (feature: mrc20)
894// ---------------------------------------------------------------------------
895
896#[cfg(feature = "mrc20")]
897mod walk {
898    use super::*;
899    use crate::mrc20::{bt_base_point, bt_trail_outputs, MempoolLookup, TxInfo};
900    use crate::payments::PaymentError;
901
902    /// How a trail's `pubkeyBase` was read.
903    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
904    pub enum BaseReading {
905        /// A full compressed point (02/03 + x): nothing guessed.
906        FullPoint,
907        /// A bare x, read as the even-y (02) point.
908        BareX,
909        /// A `did:nostr` identifier or Multikey, read by
910        /// [`bt_base_point`](crate::mrc20::bt_base_point) (this crate's
911        /// addition; blocktrails/verify reads only the two forms above).
912        Identifier,
913    }
914
915    /// The expected output of every mark, recomputed from a trail's base key
916    /// and states.
917    #[derive(Debug, Clone, PartialEq, Eq)]
918    pub struct TrailWalk {
919        /// `x(P_i)` for every mark `i`.
920        pub expected: Vec<[u8; 32]>,
921        /// The profile the trail was read as (lower-case).
922        pub profile: String,
923        /// How the base key was read.
924        pub base: BaseReading,
925    }
926
927    /// Recompute every mark's output key from a trail's base key and its
928    /// states, as blocktrails/verify 043e7af's `walk` does.
929    ///
930    /// The base key is `pubkeyBase`: a full compressed point as it is, a bare x
931    /// as the even-y point (and, beyond blocktrails/verify, a `did:nostr`
932    /// identifier or Multikey). The states are [`Blocktrail::state_strings`]:
933    /// a string state hashed as its text, an object state as its JCS, a
934    /// git-mark trail's commits read from its TXO URIs when it lists no
935    /// states. Each tweak is added to the full point, never its even-y lift.
936    ///
937    /// # Errors
938    ///
939    /// Why the trail cannot be walked, in blocktrails/verify's words where it
940    /// has them: `the trail names no base key (pubkeyBase): nothing to
941    /// recompute from`, `the trail has M marks and S states`, `mark i has no
942    /// state`; or a base key off the curve, or a zero tweak.
943    pub fn walk_blocktrail(trail: &Blocktrail) -> Result<TrailWalk, String> {
944        let profile = trail.profile_name();
945        let state_strings = trail.state_strings()?;
946        let raw = trail.pubkey_base.as_deref().unwrap_or("").to_lowercase();
947        let is_hex = |s: &str| s.bytes().all(|b| b.is_ascii_hexdigit());
948        let (base_hex, base) = if raw.len() == 66
949            && (raw.starts_with("02") || raw.starts_with("03"))
950            && is_hex(&raw)
951        {
952            (raw, BaseReading::FullPoint)
953        } else if raw.len() == 64 && is_hex(&raw) {
954            (format!("02{raw}"), BaseReading::BareX)
955        } else {
956            match bt_base_point(&raw) {
957                Ok(point) if !raw.is_empty() => (hex::encode(point), BaseReading::Identifier),
958                _ => {
959                    return Err("the trail names no base key (pubkeyBase): nothing to \
960                                    recompute from"
961                        .into())
962                }
963            }
964        };
965        let expected = bt_trail_outputs(&base_hex, &state_strings).map_err(|e| e.to_string())?;
966        Ok(TrailWalk {
967            expected,
968            profile,
969            base,
970        })
971    }
972
973    fn output_spk(tx: &TxInfo, vout: u32) -> Option<(&str, u64)> {
974        tx.vout
975            .get(vout as usize)
976            .map(|o| (o.scriptpubkey.as_deref().unwrap_or(""), o.value))
977    }
978
979    fn commits_to(spk: &str, expected: &[u8; 32]) -> Option<bool> {
980        (!spk.is_empty())
981            .then(|| spk.eq_ignore_ascii_case(&format!("5120{}", hex::encode(expected))))
982    }
983
984    /// Verify a trail mark by mark, as blocktrails/verify 043e7af does.
985    ///
986    /// First the commitment, with no network: every mark's output key is
987    /// recomputed ([`walk_blocktrail`]). Then for each mark in `txo`, through
988    /// `mempool`:
989    ///
990    /// - the transaction exists ([`MarkStatus::NotFound`] otherwise),
991    /// - its output is the recomputed key ([`MarkStatus::WrongKey`]),
992    /// - it spends the previous mark ([`MarkStatus::BrokenLink`]; see there
993    ///   for how this differs from blocktrails/verify),
994    /// - it is confirmed ([`MarkStatus::Unconfirmed`]),
995    /// - its output carries the amount the trail records
996    ///   ([`MarkStatus::AmountMismatch`]).
997    ///
998    /// A trail that cannot be walked is still checked for confirmation and
999    /// amounts, and its verdict is at best [`TrailVerdict::Confirmed`], with
1000    /// the reason in [`TrailReport::walk_error`]. A trail with no marks is
1001    /// [`TrailVerdict::Partial`].
1002    pub async fn verify_blocktrail(trail: &Blocktrail, mempool: &dyn MempoolLookup) -> TrailReport {
1003        let (walked, walk_error) = match walk_blocktrail(trail) {
1004            Ok(w) => (Some(w), None),
1005            Err(e) => (None, Some(e)),
1006        };
1007        let walk_error = if trail.txo.is_empty() {
1008            Some(walk_error.unwrap_or_else(|| "the trail has no marks".into()))
1009        } else {
1010            walk_error
1011        };
1012        let mut marks = Vec::with_capacity(trail.txo.len());
1013        for (i, t) in trail.txo.iter().enumerate() {
1014            let expected = walked.as_ref().map(|w| w.expected[i]);
1015            let mut report = MarkReport {
1016                index: i,
1017                txid: t.txid.clone(),
1018                vout: t.vout,
1019                status: MarkStatus::NotFound,
1020                confirmed: false,
1021                block_height: None,
1022                value: None,
1023                amount: t.amount,
1024                commits: None,
1025                links_to_prev: None,
1026                expected_output: expected.map(hex::encode),
1027            };
1028            if let Ok(tx) = mempool.tx(&t.txid).await {
1029                let out = output_spk(&tx, t.vout);
1030                let amount_ok = t.amount.is_none() || out.map(|(_, v)| v) == t.amount;
1031                let links = (i > 0).then(|| {
1032                    let prev = &trail.txo[i - 1].txid;
1033                    tx.vin.iter().any(|v| &v.txid == prev)
1034                });
1035                let commits = match (expected, out) {
1036                    (Some(want), Some((spk, _))) => commits_to(spk, &want),
1037                    _ => None,
1038                };
1039                report.status = mark_status(true, tx.confirmed, amount_ok, commits, links);
1040                report.confirmed = tx.confirmed;
1041                report.block_height = tx.block_height;
1042                report.value = out.map(|(_, v)| v);
1043                report.commits = commits;
1044                report.links_to_prev = links;
1045            }
1046            marks.push(report);
1047        }
1048        TrailReport::new(marks, walk_error)
1049    }
1050
1051    /// Verify a trail that keeps only its head, by walking back from it.
1052    ///
1053    /// `head_txid:head_vout` is the newest mark; each mark's predecessor is the
1054    /// output its transaction spends (with several inputs, the one whose
1055    /// output is the previous recomputed key, else the first). Every mark is
1056    /// checked as [`verify_blocktrail`] checks it, except the amount, which
1057    /// such a trail does not record. The genesis mark's own input is not
1058    /// checked: it is the funding, not part of the trail.
1059    ///
1060    /// The issued MRC20 trails keep `pubkeyBase`, their state strings and the
1061    /// head outpoint only, so this is how their every link is checked.
1062    ///
1063    /// # Errors
1064    ///
1065    /// [`PaymentError::InvalidState`] when the base key cannot be read, there
1066    /// are no states, or a tweak is zero. A mark that cannot be found is
1067    /// reported, not raised.
1068    pub async fn verify_anchor_chain(
1069        pubkey_base: &str,
1070        state_strings: &[String],
1071        head_txid: &str,
1072        head_vout: u32,
1073        mempool: &dyn MempoolLookup,
1074    ) -> Result<TrailReport, PaymentError> {
1075        if state_strings.is_empty() {
1076            return Err(PaymentError::InvalidState(
1077                "an anchor chain needs at least one state".into(),
1078            ));
1079        }
1080        let expected = bt_trail_outputs(pubkey_base, state_strings)?;
1081        let k = expected.len();
1082        let mut marks: Vec<Option<MarkReport>> = vec![None; k];
1083        let mut cursor = Some((head_txid.to_string(), head_vout));
1084        let mut cached: Option<TxInfo> = None;
1085
1086        for i in (0..k).rev() {
1087            let blank = |txid: String, vout: u32| MarkReport {
1088                index: i,
1089                txid,
1090                vout,
1091                status: MarkStatus::NotFound,
1092                confirmed: false,
1093                block_height: None,
1094                value: None,
1095                amount: None,
1096                commits: None,
1097                links_to_prev: None,
1098                expected_output: Some(hex::encode(expected[i])),
1099            };
1100            let Some((txid, vout)) = cursor.take() else {
1101                marks[i] = Some(blank(String::new(), 0));
1102                continue;
1103            };
1104            let tx = match cached.take() {
1105                Some(t) if t.txid == txid => Ok(t),
1106                _ => mempool.tx(&txid).await,
1107            };
1108            let Ok(tx) = tx else {
1109                marks[i] = Some(blank(txid, vout));
1110                continue;
1111            };
1112            let out = output_spk(&tx, vout);
1113            let commits = out.and_then(|(spk, _)| commits_to(spk, &expected[i]));
1114            let links = if i == 0 {
1115                None
1116            } else if tx.vin.is_empty() {
1117                Some(false)
1118            } else {
1119                let mut chosen = None;
1120                if tx.vin.len() > 1 {
1121                    for v in &tx.vin {
1122                        if let Ok(prev) = mempool.tx(&v.txid).await {
1123                            let hit = output_spk(&prev, v.vout)
1124                                .and_then(|(spk, _)| commits_to(spk, &expected[i - 1]))
1125                                == Some(true);
1126                            if hit {
1127                                chosen = Some((v.txid.clone(), v.vout));
1128                                cached = Some(prev);
1129                                break;
1130                            }
1131                        }
1132                    }
1133                }
1134                let (ptxid, pvout) =
1135                    chosen.unwrap_or_else(|| (tx.vin[0].txid.clone(), tx.vin[0].vout));
1136                cursor = Some((ptxid, pvout));
1137                Some(true)
1138            };
1139            let mut report = blank(txid, vout);
1140            report.status = mark_status(true, tx.confirmed, true, commits, links);
1141            report.confirmed = tx.confirmed;
1142            report.block_height = tx.block_height;
1143            report.value = out.map(|(_, v)| v);
1144            report.commits = commits;
1145            report.links_to_prev = links;
1146            marks[i] = Some(report);
1147        }
1148        Ok(TrailReport::new(
1149            marks
1150                .into_iter()
1151                .map(|m| m.expect("every mark visited"))
1152                .collect(),
1153            None,
1154        ))
1155    }
1156}
1157
1158#[cfg(feature = "mrc20")]
1159pub use walk::{verify_anchor_chain, verify_blocktrail, walk_blocktrail, BaseReading, TrailWalk};
1160
1161#[cfg(test)]
1162mod tests {
1163    use super::*;
1164    use serde_json::json;
1165
1166    const TXID: &str = "51d87101b7cbb01cc5a68785bf3141ec6fd00894d71ab1168d4daa20420eeacf";
1167    const COMMIT: &str = "9adc596cfd1100333393a12f2f41b2d820f16d0b";
1168
1169    #[test]
1170    fn txo_uri_round_trips_in_git_mark_order() {
1171        let uri = format!(
1172            "txo:tbtc4:{TXID}:0?amount=999700&pubkey={}&commit={COMMIT}",
1173            "ab".repeat(32)
1174        );
1175        let t: BlocktrailTxo = uri.parse().unwrap();
1176        assert_eq!(t.pubkey.as_deref(), Some("ab".repeat(32).as_str()));
1177        assert_eq!(t.to_string(), uri);
1178        // commit before amount reads the same and is written in git-mark's order
1179        let reordered: BlocktrailTxo = format!("txo:gitmark:{TXID}:3?commit={COMMIT}&amount=5")
1180            .parse()
1181            .unwrap();
1182        assert_eq!(
1183            reordered.to_string(),
1184            format!("txo:gitmark:{TXID}:3?amount=5&commit={COMMIT}")
1185        );
1186        // no query at all
1187        assert_eq!(
1188            BlocktrailTxo::parse_uri(&format!("txo:mainnet:{TXID}:1"))
1189                .unwrap()
1190                .to_string(),
1191            format!("txo:mainnet:{TXID}:1")
1192        );
1193    }
1194
1195    #[test]
1196    fn txo_uri_rejects_malformed() {
1197        for bad in [
1198            format!("btc:mainnet:{TXID}:0"),
1199            format!("txo:{TXID}:0"),
1200            format!("txo:tbtc4:{}:0", &TXID[..60]),
1201            format!("txo:tbtc4:{TXID}:-1"),
1202            format!("txo:tbtc4:{TXID}:x"),
1203            format!("txo:tb-tc:{TXID}:0"),
1204            format!("txo:tbtc4:{TXID}:0:9"),
1205            format!("txo:tbtc4:{TXID}:0?amount=lots"),
1206            format!("txo:tbtc4:{TXID}:0?commit=%zz"),
1207        ] {
1208            assert!(BlocktrailTxo::parse_uri(&bad).is_err(), "{bad} accepted");
1209        }
1210    }
1211
1212    #[test]
1213    fn txo_uri_empty_values_are_absent_and_percent_decoded() {
1214        let t =
1215            BlocktrailTxo::parse_uri(&format!("txo:tbtc4:{TXID}:0?amount=&commit=%61b&x=1&flag"))
1216                .unwrap();
1217        assert_eq!(t.amount, None);
1218        assert_eq!(t.commit.as_deref(), Some("ab"));
1219    }
1220
1221    #[test]
1222    fn legacy_entry_round_trips_as_written() {
1223        let v = json!({"outpoint": format!("{TXID}:2"), "blockheight": 840000});
1224        let t: BlocktrailTxo = serde_json::from_value(v.clone()).unwrap();
1225        assert!(t.is_legacy());
1226        assert_eq!(
1227            (t.txid.as_str(), t.vout, t.blockheight),
1228            (TXID, 2, Some(840_000))
1229        );
1230        assert_eq!(serde_json::to_value(&t).unwrap(), v);
1231        let no_height = json!({"outpoint": "t0:0"});
1232        let t: BlocktrailTxo = serde_json::from_value(no_height.clone()).unwrap();
1233        assert_eq!(serde_json::to_value(&t).unwrap(), no_height);
1234        assert!(serde_json::from_value::<BlocktrailTxo>(json!(7)).is_err());
1235        assert!(serde_json::from_value::<BlocktrailTxo>(json!({"outpoint": "nocolon"})).is_err());
1236    }
1237
1238    #[test]
1239    fn state_strings_follow_blocktrails_verify() {
1240        let txo = BlocktrailTxo::new("tbtc4", TXID, 0).with_commit(COMMIT);
1241        let mut t = Blocktrail::gitmark("02aa", "tbtc4", vec![], vec![txo.clone()]);
1242        // a git-mark trail with no states reads them from the TXO URIs
1243        assert_eq!(t.state_strings().unwrap(), vec![COMMIT.to_string()]);
1244        // an object state is its JCS, a string its text
1245        t.profile = Some("mono.mrc20.v0.1".into());
1246        t.states = vec![json!({"b": 1, "a": 2})];
1247        assert_eq!(
1248            t.state_strings().unwrap(),
1249            vec![r#"{"a":2,"b":1}"#.to_string()]
1250        );
1251        t.states = vec![];
1252        assert_eq!(
1253            t.state_strings().unwrap_err(),
1254            "the trail has 1 marks and 0 states"
1255        );
1256        t.states = vec![Value::Null];
1257        assert_eq!(t.state_strings().unwrap_err(), "mark 0 has no state");
1258        // no profile reads as monochrome
1259        t.profile = None;
1260        assert_eq!(t.profile_name(), DEFAULT_PROFILE);
1261        t.profile = Some("GitMark".into());
1262        assert!(t.is_gitmark());
1263    }
1264
1265    #[test]
1266    fn verdicts_follow_blocktrails_verify() {
1267        let mark = |status, commits| MarkReport {
1268            index: 0,
1269            txid: TXID.into(),
1270            vout: 0,
1271            status,
1272            confirmed: true,
1273            block_height: None,
1274            value: None,
1275            amount: None,
1276            commits,
1277            links_to_prev: None,
1278            expected_output: None,
1279        };
1280        let r = TrailReport::new(vec![mark(MarkStatus::Verified, Some(true))], None);
1281        assert!(r.is_verified() && r.is_intact());
1282        let r = TrailReport::new(
1283            vec![mark(MarkStatus::Confirmed, None)],
1284            Some("no base".into()),
1285        );
1286        assert_eq!(r.verdict, TrailVerdict::Confirmed);
1287        assert!(!r.is_intact());
1288        // walked but an output unreadable: good mark, unchecked commitment: partial
1289        let r = TrailReport::new(vec![mark(MarkStatus::Confirmed, None)], None);
1290        assert_eq!(r.verdict, TrailVerdict::Partial);
1291        let r = TrailReport::new(vec![mark(MarkStatus::Unconfirmed, Some(true))], None);
1292        assert_eq!(r.verdict, TrailVerdict::Partial);
1293        assert!(r.is_intact(), "a pending head is intact");
1294        assert_eq!(
1295            TrailReport::new(vec![], None).verdict,
1296            TrailVerdict::Partial
1297        );
1298        assert_eq!(
1299            mark_status(true, true, true, Some(true), Some(false)),
1300            MarkStatus::BrokenLink
1301        );
1302        assert_eq!(
1303            mark_status(true, false, false, Some(false), None),
1304            MarkStatus::WrongKey
1305        );
1306    }
1307
1308    #[test]
1309    fn chain_names_map_both_ways() {
1310        for (net, chain) in [
1311            ("testnet4", "tbtc4"),
1312            ("mainnet", "mainnet"),
1313            ("signet", "signet"),
1314            ("regtest", "regtest"),
1315            ("gitmark", "gitmark"),
1316        ] {
1317            assert_eq!(txo_chain_for_network(net), chain);
1318            assert_eq!(network_for_txo_chain(chain), net);
1319        }
1320        assert_eq!(network_for_txo_chain("tbtc3"), "testnet");
1321        assert!(is_gitmark_commit(&"a".repeat(64)));
1322        assert!(!is_gitmark_commit(&"g".repeat(40)));
1323    }
1324}