use std::path::Path;
use serde_json::Value;
use solid_pod_rs::blocktrail::BlocktrailTxo;
use crate::repo::browse;
pub const TRAIL_FILE: &str = "blocktrails.json";
pub const MAX_TRAIL_BYTES: u64 = 512 * 1024;
#[must_use]
pub fn did_short(hex: &str) -> String {
let head: String = hex.chars().take(8).collect();
format!("did:nostr:{head}…")
}
#[must_use]
pub fn explorer_web(chain: &str) -> Option<&'static str> {
match chain {
"btc" | "mainnet" | "bitcoin" => Some("https://mempool.space"),
"tbtc4" => Some("https://mempool.space/testnet4"),
"tbtc3" => Some("https://mempool.space/testnet"),
"signet" => Some("https://mempool.space/signet"),
"txbt4" => Some("https://mempool.guide/testnet4"),
"xbt" => Some("https://mempool.kilombino.com"),
_ => None,
}
}
#[must_use]
pub fn explorer_tx_url(chain: &str, txid: &str) -> Option<String> {
if txid.len() != 64 || !txid.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
explorer_web(chain).map(|web| format!("{web}/tx/{txid}"))
}
#[must_use]
pub fn network_for_chain(chain: &str) -> Option<&'static str> {
match chain {
"btc" | "mainnet" | "bitcoin" | "xbt" => Some("mainnet"),
"tbtc4" | "tbtc3" | "signet" | "txbt4" => Some("testnet4"),
"gitmark" => Some("gitmark"),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoMark {
pub index: usize,
pub commit: String,
pub program: Option<String>,
pub address: Option<String>,
pub txo: Option<BlocktrailTxo>,
pub txo_uri: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RepoTrail {
pub doc: Value,
pub branch: String,
pub chain: String,
pub pubkey_base: String,
pub marks: Vec<RepoMark>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnchorFacts {
pub chain: String,
pub genesis_txid: String,
pub frontier_txid: String,
pub frontier_vout: u32,
pub frontier_commit: String,
pub trail_url: String,
}
impl RepoTrail {
#[must_use]
pub fn xonly(&self) -> String {
self.pubkey_base[2..].to_ascii_lowercase()
}
#[must_use]
pub fn anchor_facts(&self, trail_url: &str) -> Option<AnchorFacts> {
let first = self.marks.iter().find_map(|m| m.txo.as_ref())?;
let (last_mark, last) = self
.marks
.iter()
.rev()
.find_map(|m| m.txo.as_ref().map(|t| (m, t)))?;
Some(AnchorFacts {
chain: self.chain.clone(),
genesis_txid: first.txid.to_ascii_lowercase(),
frontier_txid: last.txid.to_ascii_lowercase(),
frontier_vout: last.vout,
frontier_commit: last_mark.commit.clone(),
trail_url: trail_url.to_string(),
})
}
#[must_use]
pub fn document_json(&self) -> String {
serde_json::to_string_pretty(&self.doc).unwrap_or_else(|_| "{}".to_string())
}
}
#[must_use]
pub fn anchor_tags(facts: &AnchorFacts) -> Vec<Vec<String>> {
vec![
vec!["r".into(), facts.trail_url.clone()],
vec![
"anchor".into(),
facts.chain.clone(),
facts.genesis_txid.clone(),
],
vec!["chain".into(), facts.chain.clone()],
vec![
"txo".into(),
format!(
"txo:{}:{}:{}",
facts.chain, facts.frontier_txid, facts.frontier_vout
),
],
vec!["commit".into(), facts.frontier_commit.clone()],
]
}
fn is_base_point(s: &str) -> bool {
s.len() == 66
&& (s.starts_with("02") || s.starts_with("03"))
&& s.bytes().all(|b| b.is_ascii_hexdigit())
}
fn is_commit(s: &str) -> bool {
s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
}
#[must_use]
pub fn parse_repo_trail(raw: &str, branch: &str) -> Option<RepoTrail> {
if raw.len() as u64 > MAX_TRAIL_BYTES {
return None;
}
let doc: Value = serde_json::from_str(raw).ok()?;
if doc.get("@type")?.as_str()? != "Blocktrail" || doc.get("profile")?.as_str()? != "gitmark" {
return None;
}
let pubkey_base = doc.get("pubkeyBase")?.as_str()?.to_string();
if !is_base_point(&pubkey_base) {
return None;
}
let chain = doc.get("chain")?.as_str()?.to_string();
let commits: Vec<String> = doc
.get("states")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.filter(|c| is_commit(c))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let txo_uris: Vec<Option<String>> = doc
.get("txo")
.and_then(Value::as_array)
.map(|a| a.iter().map(|v| v.as_str().map(str::to_string)).collect())
.unwrap_or_default();
let (programs, addresses) = derive(&pubkey_base, &chain, &commits);
let marks = commits
.into_iter()
.enumerate()
.map(|(index, commit)| {
let txo_uri = txo_uris.get(index).cloned().flatten();
let txo = txo_uri
.as_deref()
.and_then(|u| BlocktrailTxo::parse_uri(u).ok());
RepoMark {
index,
commit,
program: programs.get(index).cloned(),
address: addresses.get(index).cloned(),
txo,
txo_uri,
}
})
.collect();
Some(RepoTrail {
doc,
branch: branch.to_string(),
chain,
pubkey_base,
marks,
})
}
#[cfg(feature = "anchoring")]
fn derive(pubkey_base: &str, chain: &str, commits: &[String]) -> (Vec<String>, Vec<String>) {
use solid_pod_rs::mrc20::{bt_trail_addresses, bt_trail_outputs};
let Ok(outputs) = bt_trail_outputs(pubkey_base, commits) else {
return (Vec::new(), Vec::new());
};
let programs = outputs.iter().map(hex::encode).collect();
let addresses = network_for_chain(chain)
.and_then(|net| bt_trail_addresses(pubkey_base, commits, net).ok())
.unwrap_or_default();
(programs, addresses)
}
#[cfg(not(feature = "anchoring"))]
fn derive(_pubkey_base: &str, _chain: &str, _commits: &[String]) -> (Vec<String>, Vec<String>) {
(Vec::new(), Vec::new())
}
pub async fn read_repo_trail(git_dir: &Path, branch: &str) -> Option<RepoTrail> {
let rev = format!("refs/heads/{branch}");
let bytes = browse::read_blob(git_dir, &rev, TRAIL_FILE, MAX_TRAIL_BYTES)
.await
.ok()?;
parse_repo_trail(std::str::from_utf8(&bytes).ok()?, branch)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const BASE: &str = "034f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa";
const C0: &str = "1111111111111111111111111111111111111111";
const C1: &str = "2222222222222222222222222222222222222222";
#[cfg(feature = "anchoring")]
const P0: &str = "f1f4e2a2289fbfbae2f5d10e8a554d777a44c97dd5593db7df947acf1418532b";
#[cfg(feature = "anchoring")]
const P1: &str = "4e3706f136ba546fe29bbb93c5a20903cbdfbf71f6ff81f611501dae7bf84743";
#[cfg(feature = "anchoring")]
const A0_TB: &str = "tb1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4sf843nu";
#[cfg(feature = "anchoring")]
const A1_TB: &str = "tb1pfcmsdufkhf2xlc5mhwfutgsfq09al0m37mlcras32qw6u7lcgapslzrkyu";
#[cfg(feature = "anchoring")]
const A0_BC: &str = "bc1p786w9g3gn7lm4ch46y8g542dwaayfjta64vnmd7lj3av79qc2v4s70r7fn";
fn txid_a() -> String {
"ab".repeat(32)
}
fn txid_b() -> String {
"cd".repeat(32)
}
fn doc(chain: &str) -> String {
json!({
"@type": "Blocktrail", "version": "0.0.3", "profile": "gitmark",
"pubkeyBase": BASE, "chain": chain,
"states": [C0, C1],
"txo": [
format!("txo:{chain}:{}:0?amount=9700&commit={C0}", txid_a()),
format!("txo:{chain}:{}:0?amount=9400&commit={C1}", txid_b()),
],
})
.to_string()
}
#[test]
fn did_short_is_eight_chars_and_an_ellipsis() {
let hex = format!("4a578b54{}", "ab".repeat(28));
assert_eq!(did_short(&hex), "did:nostr:4a578b54…");
}
#[test]
fn explorer_table_matches_the_git_mark_chains() {
assert_eq!(explorer_web("btc"), Some("https://mempool.space"));
assert_eq!(explorer_web("tbtc3"), Some("https://mempool.space/testnet"));
assert_eq!(explorer_web("signet"), Some("https://mempool.space/signet"));
assert_eq!(explorer_web("regtest"), None);
assert_eq!(
explorer_tx_url("txbt4", &txid_b()).unwrap(),
format!("https://mempool.guide/testnet4/tx/{}", txid_b())
);
assert!(explorer_tx_url("txbt4", &txid_b()[..10]).is_none());
assert!(explorer_tx_url("regtest", &txid_b()).is_none());
}
#[test]
fn parses_the_committed_document() {
let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
assert_eq!(t.chain, "txbt4");
assert_eq!(t.branch, "main");
assert_eq!(t.marks.len(), 2);
assert_eq!(t.marks[1].commit, C1);
let txo = t.marks[1].txo.as_ref().unwrap();
assert_eq!((txo.vout, txo.amount), (0, Some(9400)));
assert_eq!(txo.txid, txid_b());
assert_eq!(t.xonly(), &BASE[2..]);
let served: Value = serde_json::from_str(&t.document_json()).unwrap();
assert_eq!(served["profile"], "gitmark");
assert_eq!(served["states"], json!([C0, C1]));
assert_eq!(served["pubkeyBase"], BASE);
}
#[test]
fn refuses_what_is_not_a_repo_held_gitmark_trail() {
for bad in [
"not json".to_string(),
r#"{"@type":"Blocktrail","profile":"monochrome","pubkeyBase":"x","chain":"tbtc4"}"#
.to_string(),
json!({"@type":"Other","profile":"gitmark","pubkeyBase":BASE,"chain":"tbtc4"})
.to_string(),
json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":&BASE[2..],"chain":"tbtc4"})
.to_string(),
json!({"@type":"Blocktrail","profile":"gitmark","pubkeyBase":BASE}).to_string(),
] {
assert!(parse_repo_trail(&bad, "main").is_none(), "{bad}");
}
let huge = format!("{{\"pad\":\"{}\"}}", "x".repeat(MAX_TRAIL_BYTES as usize));
assert!(parse_repo_trail(&huge, "main").is_none());
}
#[test]
fn drops_malformed_states_and_tolerates_bad_txo_entries() {
let raw = json!({
"@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
"states": [C0, "short", 7, C1],
"txo": ["garbage", null],
})
.to_string();
let t = parse_repo_trail(&raw, "main").unwrap();
assert_eq!(t.marks.len(), 2, "only the two commits survive");
assert!(t.marks.iter().all(|m| m.txo.is_none()));
assert_eq!(t.marks[0].txo_uri.as_deref(), Some("garbage"));
assert!(t.anchor_facts("https://x/blocktrails.json").is_none());
}
#[cfg(feature = "anchoring")]
#[test]
fn re_derives_addresses_with_gitmark_state_bytes() {
let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
assert_eq!(t.marks[0].program.as_deref(), Some(P0));
assert_eq!(t.marks[1].program.as_deref(), Some(P1));
assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
assert_eq!(t.marks[1].address.as_deref(), Some(A1_TB));
let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
assert_eq!(t.marks[0].address.as_deref(), Some(A0_TB));
let t = parse_repo_trail(&doc("xbt"), "main").unwrap();
assert_eq!(t.marks[0].address.as_deref(), Some(A0_BC));
let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
let forge_held = solid_pod_rs::mrc20::bt_address(
BASE,
&[format!(
r#"{{"commit":"{C0}","repo":"casey/agent","branch":"main"}}"#
)],
"testnet4",
)
.unwrap();
assert_ne!(t.marks[0].address.as_deref(), Some(forge_held.as_str()));
}
#[cfg(feature = "anchoring")]
#[test]
fn unknown_chain_has_programs_but_no_address() {
let t = parse_repo_trail(&doc("regtest"), "main").unwrap();
assert_eq!(t.marks[0].program.as_deref(), Some(P0));
assert!(t.marks.iter().all(|m| m.address.is_none()));
}
#[cfg(not(feature = "anchoring"))]
#[test]
fn without_anchoring_nothing_is_derived() {
let t = parse_repo_trail(&doc("tbtc4"), "main").unwrap();
assert!(t
.marks
.iter()
.all(|m| m.program.is_none() && m.address.is_none()));
}
#[test]
fn anchor_tags_name_the_latest_mark_and_the_genesis() {
let t = parse_repo_trail(&doc("txbt4"), "main").unwrap();
let url = "https://forge.example/forge/casey/agent/blocktrails.json";
let tags = anchor_tags(&t.anchor_facts(url).unwrap());
let val = |k: &str| tags.iter().find(|t| t[0] == k).unwrap()[1..].to_vec();
assert_eq!(val("chain"), ["txbt4"]);
assert_eq!(
val("txo"),
[format!("txo:txbt4:{}:0", txid_b())],
"the latest mark, not the genesis"
);
assert_eq!(val("commit"), [C1]);
assert_eq!(val("anchor"), ["txbt4".to_string(), txid_a()]);
assert_eq!(val("r"), [url]);
}
#[test]
fn a_trail_with_no_onchain_mark_names_no_anchor() {
let raw = json!({
"@type": "Blocktrail", "profile": "gitmark", "pubkeyBase": BASE, "chain": "tbtc4",
"states": [C0], "txo": [],
})
.to_string();
let t = parse_repo_trail(&raw, "main").unwrap();
assert!(t.anchor_facts("https://x").is_none());
}
#[tokio::test]
async fn reads_the_trail_from_a_bare_repository_and_ignores_its_absence() {
use std::process::Command;
let git_ok = Command::new("git").arg("--version").output().is_ok();
if !git_ok {
return;
}
let td = tempfile::tempdir().unwrap();
let work = td.path().join("work");
let bare = td.path().join("agent.git");
let run = |dir: &Path, args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@example.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@example.com")
.output()
.unwrap();
assert!(out.status.success(), "git {args:?}: {out:?}");
};
std::fs::create_dir(&work).unwrap();
run(&work, &["init", "--quiet", "-b", "main"]);
std::fs::write(work.join("a.txt"), "first\n").unwrap();
run(&work, &["add", "-A"]);
run(&work, &["commit", "--quiet", "-m", "first"]);
run(
td.path(),
&[
"clone",
"--quiet",
"--bare",
work.to_str().unwrap(),
"agent.git",
],
);
assert!(read_repo_trail(&bare, "main").await.is_none());
std::fs::write(work.join(TRAIL_FILE), doc("txbt4")).unwrap();
run(&work, &["add", "-A"]);
run(&work, &["commit", "--quiet", "-m", "trail"]);
run(&work, &["push", "--quiet", bare.to_str().unwrap(), "main"]);
let t = read_repo_trail(&bare, "main").await.unwrap();
assert_eq!(t.marks.len(), 2);
assert!(read_repo_trail(&bare, "--output=/etc/x").await.is_none());
}
}