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(¶ms.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}