1use std::path::Path;
32
33use serde_json::Value;
34use solid_pod_rs::blocktrail::BlocktrailTxo;
35
36use crate::repo::browse;
37
38pub const TRAIL_FILE: &str = "blocktrails.json";
40
41pub const MAX_TRAIL_BYTES: u64 = 512 * 1024;
45
46#[must_use]
61pub fn did_short(hex: &str) -> String {
62 let head: String = hex.chars().take(8).collect();
63 format!("did:nostr:{head}…")
64}
65
66#[must_use]
84pub fn explorer_web(chain: &str) -> Option<&'static str> {
85 match chain {
86 "btc" | "mainnet" | "bitcoin" => Some("https://mempool.space"),
87 "tbtc4" => Some("https://mempool.space/testnet4"),
88 "tbtc3" => Some("https://mempool.space/testnet"),
89 "signet" => Some("https://mempool.space/signet"),
90 "txbt4" => Some("https://mempool.guide/testnet4"),
91 "xbt" => Some("https://mempool.kilombino.com"),
92 _ => None,
93 }
94}
95
96#[must_use]
112pub fn explorer_tx_url(chain: &str, txid: &str) -> Option<String> {
113 if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
114 return None;
115 }
116 explorer_web(chain).map(|web| format!("{web}/tx/{txid}"))
117}
118
119#[must_use]
137pub fn network_for_chain(chain: &str) -> Option<&'static str> {
138 match chain {
139 "btc" | "mainnet" | "bitcoin" | "xbt" => Some("mainnet"),
140 "tbtc4" | "tbtc3" | "signet" | "txbt4" => Some("testnet4"),
141 "gitmark" => Some("gitmark"),
142 _ => None,
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct RepoMark {
149 pub index: usize,
151 pub commit: String,
153 pub program: Option<String>,
157 pub address: Option<String>,
160 pub txo: Option<BlocktrailTxo>,
162 pub txo_uri: Option<String>,
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct RepoTrail {
169 pub doc: Value,
171 pub branch: String,
173 pub chain: String,
175 pub pubkey_base: String,
177 pub marks: Vec<RepoMark>,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct AnchorFacts {
184 pub chain: String,
186 pub genesis_txid: String,
188 pub frontier_txid: String,
190 pub frontier_vout: u32,
192 pub frontier_commit: String,
194 pub trail_url: String,
196}
197
198impl RepoTrail {
199 #[must_use]
202 pub fn xonly(&self) -> String {
203 self.pubkey_base[2..].to_ascii_lowercase()
204 }
205
206 #[must_use]
209 pub fn anchor_facts(&self, trail_url: &str) -> Option<AnchorFacts> {
210 let first = self.marks.iter().find_map(|m| m.txo.as_ref())?;
211 let (last_mark, last) = self
212 .marks
213 .iter()
214 .rev()
215 .find_map(|m| m.txo.as_ref().map(|t| (m, t)))?;
216 Some(AnchorFacts {
217 chain: self.chain.clone(),
218 genesis_txid: first.txid.to_ascii_lowercase(),
219 frontier_txid: last.txid.to_ascii_lowercase(),
220 frontier_vout: last.vout,
221 frontier_commit: last_mark.commit.clone(),
222 trail_url: trail_url.to_string(),
223 })
224 }
225
226 #[must_use]
228 pub fn document_json(&self) -> String {
229 serde_json::to_string_pretty(&self.doc).unwrap_or_else(|_| "{}".to_string())
230 }
231}
232
233#[must_use]
259pub fn anchor_tags(facts: &AnchorFacts) -> Vec<Vec<String>> {
260 vec![
261 vec!["r".into(), facts.trail_url.clone()],
262 vec![
263 "anchor".into(),
264 facts.chain.clone(),
265 facts.genesis_txid.clone(),
266 ],
267 vec!["chain".into(), facts.chain.clone()],
268 vec![
269 "txo".into(),
270 format!(
271 "txo:{}:{}:{}",
272 facts.chain, facts.frontier_txid, facts.frontier_vout
273 ),
274 ],
275 vec!["commit".into(), facts.frontier_commit.clone()],
276 ]
277}
278
279fn is_base_point(s: &str) -> bool {
280 s.len() == 66
281 && (s.starts_with("02") || s.starts_with("03"))
282 && s.bytes().all(|b| b.is_ascii_hexdigit())
283}
284
285fn is_commit(s: &str) -> bool {
288 s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
289}
290
291#[must_use]
319pub fn parse_repo_trail(raw: &str, branch: &str) -> Option<RepoTrail> {
320 if raw.len() as u64 > MAX_TRAIL_BYTES {
321 return None;
322 }
323 let doc: Value = serde_json::from_str(raw).ok()?;
324 if doc.get("@type")?.as_str()? != "Blocktrail" || doc.get("profile")?.as_str()? != "gitmark" {
325 return None;
326 }
327 let pubkey_base = doc.get("pubkeyBase")?.as_str()?.to_string();
328 if !is_base_point(&pubkey_base) {
329 return None;
330 }
331 let chain = doc.get("chain")?.as_str()?.to_string();
332 let commits: Vec<String> = doc
333 .get("states")
334 .and_then(Value::as_array)
335 .map(|a| {
336 a.iter()
337 .filter_map(Value::as_str)
338 .filter(|c| is_commit(c))
339 .map(str::to_string)
340 .collect()
341 })
342 .unwrap_or_default();
343 let txo_uris: Vec<Option<String>> = doc
344 .get("txo")
345 .and_then(Value::as_array)
346 .map(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
347 .unwrap_or_default();
348
349 let (programs, addresses) = derive(&pubkey_base, &chain, &commits);
350 let marks = commits
351 .into_iter()
352 .enumerate()
353 .map(|(index, commit)| {
354 let txo_uri = txo_uris.get(index).cloned().flatten();
355 let txo = txo_uri
356 .as_deref()
357 .and_then(|u| BlocktrailTxo::parse_uri(u).ok());
358 RepoMark {
359 index,
360 commit,
361 program: programs.get(index).cloned(),
362 address: addresses.get(index).cloned(),
363 txo,
364 txo_uri,
365 }
366 })
367 .collect();
368 Some(RepoTrail {
369 doc,
370 branch: branch.to_string(),
371 chain,
372 pubkey_base,
373 marks,
374 })
375}
376
377#[cfg(feature = "anchoring")]
381fn derive(pubkey_base: &str, chain: &str, commits: &[String]) -> (Vec<String>, Vec<String>) {
382 use solid_pod_rs::mrc20::{bt_trail_addresses, bt_trail_outputs};
383 let Ok(outputs) = bt_trail_outputs(pubkey_base, commits) else {
384 return (Vec::new(), Vec::new());
385 };
386 let programs = outputs.iter().map(hex::encode).collect();
387 let addresses = network_for_chain(chain)
388 .and_then(|net| bt_trail_addresses(pubkey_base, commits, net).ok())
389 .unwrap_or_default();
390 (programs, addresses)
391}
392
393#[cfg(not(feature = "anchoring"))]
394fn derive(_pubkey_base: &str, _chain: &str, _commits: &[String]) -> (Vec<String>, Vec<String>) {
395 (Vec::new(), Vec::new())
396}
397
398pub async fn read_repo_trail(git_dir: &Path, branch: &str) -> Option<RepoTrail> {
407 let rev = format!("refs/heads/{branch}");
408 let bytes = browse::read_blob(git_dir, &rev, TRAIL_FILE, MAX_TRAIL_BYTES)
409 .await
410 .ok()?;
411 parse_repo_trail(std::str::from_utf8(&bytes).ok()?, branch)
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417 use serde_json::json;
418
419 const BASE: &str = "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
423 const C0: &str = "1111111111111111111111111111111111111111";
424 const C1: &str = "2222222222222222222222222222222222222222";
425 #[cfg(feature = "anchoring")]
426 const P0: &str = "f1f4e2a2289fbfbae2f5d10e8a554d777a44c97dd5593db7df947acf1418532b";
427 #[cfg(feature = "anchoring")]
428 const P1: &str = "4e3706f136ba546fe29bbb93c5a20903cbdfbf71f6ff81f611501dae7bf84743";
429 #[cfg(feature = "anchoring")]
430 const A0_TB: &str = "tb1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4sf843nu";
431 #[cfg(feature = "anchoring")]
432 const A1_TB: &str = "tb1pfcmsdufkhf2xlc5mhwfutgsfq09al0m37mlcras32qw6u7lcgapslzrkyu";
433 #[cfg(feature = "anchoring")]
434 const A0_BC: &str = "bc1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4s70r7fn";
435
436 fn txid_a() -> String {
437 "ab".repeat(32)
438 }
439 fn txid_b() -> String {
440 "cd".repeat(32)
441 }
442
443 fn doc(chain: &str) -> String {
444 json!({
445 "@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
446 "pubkeyBase": BASE, "chain": chain,
447 "states": [C0, C1],
448 "txo": [
449 format!("txo:{chain}:{}:0?amount=9700&commit={C0}", txid_a()),
450 format!("txo:{chain}:{}:0?amount=9400&commit={C1}", txid_b()),
451 ],
452 })
453 .to_string()
454 }
455
456 #[test]
457 fn did_short_is_eight_chars_and_an_ellipsis() {
458 let hex = format!("4a578b54{}", "ab".repeat(28));
459 assert_eq!(did_short(&hex), "did:nostr:4a578b54…");
460 }
461
462 #[test]
463 fn explorer_table_matches_the_git_mark_chains() {
464 assert_eq!(explorer_web("btc"), Some("https://mempool.space"));
465 assert_eq!(explorer_web("tbtc3"), Some("https://mempool.space/testnet"));
466 assert_eq!(explorer_web("signet"), Some("https://mempool.space/signet"));
467 assert_eq!(explorer_web("regtest"), None);
468 assert_eq!(
469 explorer_tx_url("txbt4", &txid_b()).unwrap(),
470 format!("https://mempool.guide/testnet4/tx/{}", txid_b())
471 );
472 assert!(explorer_tx_url("txbt4", &txid_b()[..10]).is_none());
473 assert!(explorer_tx_url("regtest", &txid_b()).is_none());
474 }
475
476 #[test]
477 fn parses_the_committed_document() {
478 let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
479 assert_eq!(t.chain, "txbt4");
480 assert_eq!(t.branch, "main");
481 assert_eq!(t.marks.len(), 2);
482 assert_eq!(t.marks[1].commit, C1);
483 let txo = t.marks[1].txo.as_ref().unwrap();
484 assert_eq!((txo.vout, txo.amount), (0, Some(9400)));
485 assert_eq!(txo.txid, txid_b());
486 assert_eq!(t.xonly(), &BASE[2..]);
487 let served: Value = serde_json::from_str(&t.document_json()).unwrap();
489 assert_eq!(served["profile"], "gitmark");
490 assert_eq!(served["states"], json!([C0, C1]));
491 assert_eq!(served["pubkeyBase"], BASE);
492 }
493
494 #[test]
495 fn refuses_what_is_not_a_repo_held_gitmark_trail() {
496 for bad in [
497 "not json".to_string(),
498 r#"{"@type":"Blocktrail","profile":"monochrome","pubkeyBase":"x","chain":"tbtc4"}"#
499 .to_string(),
500 json!({"@type":"Other","profile":"gitmark","pubkeyBase":BASE,"chain":"tbtc4"})
501 .to_string(),
502 json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":&BASE[2..],"chain":"tbtc4"})
504 .to_string(),
505 json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":BASE}).to_string(),
506 ] {
507 assert!(parse_repo_trail(&bad, "main").is_none(), "{bad}");
508 }
509 let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(MAX_TRAIL_BYTES as usize));
510 assert!(parse_repo_trail(&huge, "main").is_none());
511 }
512
513 #[test]
514 fn drops_malformed_states_and_tolerates_bad_txo_entries() {
515 let raw = json!({
516 "@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
517 "states": [C0, "short", 7, C1],
518 "txo": ["garbage", null],
519 })
520 .to_string();
521 let t = parse_repo_trail(&raw, "main").unwrap();
522 assert_eq!(t.marks.len(), 2, "only the two commits survive");
523 assert!(t.marks.iter().all(|m| m.txo.is_none()));
524 assert_eq!(t.marks[0].txo_uri.as_deref(), Some("garbage"));
525 assert!(t.anchor_facts("https://x/blocktrails.json").is_none());
526 }
527
528 #[cfg(feature = "anchoring")]
529 #[test]
530 fn re_derives_addresses_with_gitmark_state_bytes() {
531 let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
532 assert_eq!(t.marks[0].program.as_deref(), Some(P0));
533 assert_eq!(t.marks[1].program.as_deref(), Some(P1));
534 assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
535 assert_eq!(t.marks[1].address.as_deref(), Some(A1_TB));
536 let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
538 assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
539 let t = parse_repo_trail(&doc("xbt"), "main").unwrap();
540 assert_eq!(t.marks[0].address.as_deref(), Some(A0_BC));
541 let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
544 let forge_held = solid_pod_rs::mrc20::bt_address(
545 BASE,
546 &[format!(
547 r#"{{"commit":"{C0}","repo":"casey/agent","branch":"main"}}"#
548 )],
549 "testnet4",
550 )
551 .unwrap();
552 assert_ne!(t.marks[0].address.as_deref(), Some(forge_held.as_str()));
553 }
554
555 #[cfg(feature = "anchoring")]
556 #[test]
557 fn unknown_chain_has_programs_but_no_address() {
558 let t = parse_repo_trail(&doc("regtest"), "main").unwrap();
559 assert_eq!(t.marks[0].program.as_deref(), Some(P0));
560 assert!(t.marks.iter().all(|m| m.address.is_none()));
561 }
562
563 #[cfg(not(feature = "anchoring"))]
564 #[test]
565 fn without_anchoring_nothing_is_derived() {
566 let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
567 assert!(t
568 .marks
569 .iter()
570 .all(|m| m.program.is_none() && m.address.is_none()));
571 }
572
573 #[test]
574 fn anchor_tags_name_the_latest_mark_and_the_genesis() {
575 let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
576 let url = "https://forge.example/forge/casey/agent/blocktrails.json";
577 let tags = anchor_tags(&t.anchor_facts(url).unwrap());
578 let val = |k: &str| tags.iter().find(|t| t[0] == k).unwrap()[1..].to_vec();
579 assert_eq!(val("chain"), ["txbt4"]);
580 assert_eq!(
581 val("txo"),
582 [format!("txo:txbt4:{}:0", txid_b())],
583 "the latest mark, not the genesis"
584 );
585 assert_eq!(val("commit"), [C1]);
586 assert_eq!(val("anchor"), ["txbt4".to_string(), txid_a()]);
587 assert_eq!(val("r"), [url]);
588 }
589
590 #[test]
591 fn a_trail_with_no_onchain_mark_names_no_anchor() {
592 let raw = json!({
593 "@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
594 "states": [C0], "txo": [],
595 })
596 .to_string();
597 let t = parse_repo_trail(&raw, "main").unwrap();
598 assert!(t.anchor_facts("https://x").is_none());
599 }
600
601 #[tokio::test]
602 async fn reads_the_trail_from_a_bare_repository_and_ignores_its_absence() {
603 use std::process::Command;
604 let git_ok = Command::new("git").arg("--version").output().is_ok();
605 if !git_ok {
606 return;
607 }
608 let td = tempfile::tempdir().unwrap();
609 let work = td.path().join("work");
610 let bare = td.path().join("agent.git");
611 let run = |dir: &Path, args: &[&str]| {
612 let out = Command::new("git")
613 .args(args)
614 .current_dir(dir)
615 .env("GIT_AUTHOR_NAME", "t")
616 .env("GIT_AUTHOR_EMAIL", "t@example.com")
617 .env("GIT_COMMITTER_NAME", "t")
618 .env("GIT_COMMITTER_EMAIL", "t@example.com")
619 .output()
620 .unwrap();
621 assert!(out.status.success(), "git {args:?}: {out:?}");
622 };
623 std::fs::create_dir(&work).unwrap();
624 run(&work, &["init", "--quiet", "-b", "main"]);
625 std::fs::write(work.join("a.txt"), "first\n").unwrap();
626 run(&work, &["add", "-A"]);
627 run(&work, &["commit", "--quiet", "-m", "first"]);
628 run(
629 td.path(),
630 &[
631 "clone",
632 "--quiet",
633 "--bare",
634 work.to_str().unwrap(),
635 "agent.git",
636 ],
637 );
638 assert!(read_repo_trail(&bare, "main").await.is_none());
640
641 std::fs::write(work.join(TRAIL_FILE), doc("txbt4")).unwrap();
642 run(&work, &["add", "-A"]);
643 run(&work, &["commit", "--quiet", "-m", "trail"]);
644 run(&work, &["push", "--quiet", bare.to_str().unwrap(), "main"]);
645 let t = read_repo_trail(&bare, "main").await.unwrap();
646 assert_eq!(t.marks.len(), 2);
647 assert!(read_repo_trail(&bare, "--output=/etc/x").await.is_none());
649 }
650}