use bitcoin::{Script, ScriptBuf, Transaction, TxOut};
use crate::error::{Error, Result};
pub fn op_return_data(spk: &Script) -> Option<&[u8]> {
let b = spk.as_bytes();
if b.len() < 2 || b[0] != 0x6a {
return None;
}
let (len, data) = if b[1] == 0x4c {
if b.len() < 3 {
return None;
}
(usize::from(b[2]), &b[3..])
} else {
(usize::from(b[1]), &b[2..])
};
(len == data.len()).then_some(data)
}
const BOM: &[u8] = b"\xef\xbb\xbf";
fn without_bom(d: &[u8]) -> &[u8] {
d.strip_prefix(BOM).unwrap_or(d)
}
fn marker_text(d: &[u8]) -> Option<&str> {
std::str::from_utf8(without_bom(d)).ok()
}
pub fn record_text(spk: &Script) -> Option<String> {
let b = spk.as_bytes();
if b.len() < 2 || b[0] != 0x6a {
return None;
}
let (len, data) = if b[1] == 0x4c {
if b.len() < 3 || b[2] <= 75 {
return None;
}
(usize::from(b[2]), &b[3..])
} else {
if b[1] > 75 {
return None;
}
(usize::from(b[1]), &b[2..])
};
if data.len() != len {
return None;
}
marker_text(data).map(str::to_string)
}
pub fn record_script(text: &str) -> Result<ScriptBuf> {
let b = text.as_bytes();
if b.len() > 255 {
return Err(Error::Block("a record is at most 255 bytes".into()));
}
Ok(op_return(b))
}
fn op_return(data: &[u8]) -> ScriptBuf {
let mut out = vec![0x6a];
match data.len() {
n @ 0..=75 => out.push(n as u8),
n @ 76..=255 => out.extend_from_slice(&[0x4c, n as u8]),
n @ 256..=65_535 => {
out.push(0x4d);
out.extend_from_slice(&(n as u16).to_le_bytes());
}
n => {
out.push(0x4e);
out.extend_from_slice(&(n as u32).to_le_bytes());
}
}
out.extend_from_slice(data);
ScriptBuf::from_bytes(out)
}
fn is_lower_hex(s: &str) -> bool {
s.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
pub fn peg_marker_data(chain_id: &str, script: &Script) -> Vec<u8> {
let head = format!("pegin:{chain_id}:");
if head.len() + script.len() <= 80 {
let mut out = head.into_bytes();
out.extend_from_slice(script.as_bytes());
out
} else {
format!("{head}{}", hex::encode(script.as_bytes())).into_bytes()
}
}
pub fn parse_peg_marker(spk: &Script, chain_id: &str) -> Option<ScriptBuf> {
let d = op_return_data(spk)?;
let head = format!("pegin:{chain_id}:");
let rest = d.strip_prefix(head.as_bytes())?;
if rest.is_empty() {
return None;
}
if let Some(text) = marker_text(rest) {
if !text.is_empty() && text.len() % 2 == 0 && text.bytes().all(|b| b.is_ascii_hexdigit()) {
return hex::decode(text.to_ascii_lowercase())
.ok()
.map(ScriptBuf::from_bytes);
}
}
Some(ScriptBuf::from_bytes(rest.to_vec()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Payout {
pub index: usize,
pub value: u64,
pub script_pubkey: ScriptBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Claim {
pub index: usize,
pub txid: String,
pub vout: u32,
pub payout: Payout,
}
pub const CLAIM_VOUT_MAX: u32 = 99_999;
pub fn claim_marker(txid: &str, vout: u32) -> ScriptBuf {
op_return(format!("claim:{txid}:{vout}").as_bytes())
}
pub fn try_claim_marker(txid: &str, vout: u32) -> Result<ScriptBuf> {
if txid.len() != 64 || !is_lower_hex(txid) {
return Err(Error::Encoding(
"a claim names its parent txid as 64 lower-hex characters".into(),
));
}
if vout > CLAIM_VOUT_MAX {
return Err(Error::Encoding(format!(
"a claim's vout is at most {CLAIM_VOUT_MAX} (five decimal digits), not {vout}"
)));
}
Ok(claim_marker(txid, vout))
}
pub fn parse_claims(coinbase: &Transaction) -> (Vec<Claim>, Vec<String>) {
let mut claims = Vec::new();
let mut errors = Vec::new();
for (i, o) in coinbase.output.iter().enumerate() {
let Some(d) = op_return_data(&o.script_pubkey) else {
continue;
};
let Some(t) = marker_text(d) else {
continue;
};
let Some((txid, vout)) = parse_claim_text(t) else {
continue;
};
let payout = i.checked_sub(1).map(|p| &coinbase.output[p]);
match payout {
Some(p) if !p.script_pubkey.is_op_return() && p.value.to_sat() > 0 => {
claims.push(Claim {
index: i,
txid,
vout,
payout: Payout {
index: i - 1,
value: p.value.to_sat(),
script_pubkey: p.script_pubkey.clone(),
},
})
}
_ => errors.push(format!("claim at output {i} has no payout before it")),
}
}
(claims, errors)
}
fn parse_claim_text(t: &str) -> Option<(String, u32)> {
let rest = t.strip_prefix("claim:")?;
let (txid, vout) = rest.split_once(':')?;
if txid.len() != 64 || !is_lower_hex(txid) {
return None;
}
if vout.is_empty() || vout.len() > 5 || !vout.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
Some((txid.to_string(), vout.parse().ok()?))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Burn {
pub txid: String,
pub vout: u32,
pub script: String,
pub value: u64,
pub height: u32,
}
pub fn pegout_marker(script_hex: &str) -> ScriptBuf {
op_return(format!("pegout:{}", script_hex.to_ascii_lowercase()).as_bytes())
}
pub fn try_pegout_marker(script_hex: &str) -> Result<ScriptBuf> {
let s = script_hex.to_ascii_lowercase();
if s.len() % 2 != 0 || !(4..=80).contains(&s.len()) || !is_lower_hex(&s) {
return Err(Error::Encoding(format!(
"a burn names a parent output script of 2 to 40 bytes as hex, not {script_hex:?}"
)));
}
Ok(pegout_marker(&s))
}
pub fn parse_pegout(spk: &Script) -> Option<String> {
let d = op_return_data(spk)?;
let t = marker_text(d)?;
let s = t.strip_prefix("pegout:")?;
(s.len() % 2 == 0 && (4..=80).contains(&s.len()) && is_lower_hex(s)).then(|| s.to_string())
}
pub fn parse_pegouts(tx: &Transaction, txid: &str, height: u32) -> Vec<Burn> {
tx.output
.iter()
.enumerate()
.filter_map(|(i, o)| {
parse_pegout(&o.script_pubkey).map(|script| Burn {
txid: txid.to_string(),
vout: i as u32,
script,
value: o.value.to_sat(),
height,
})
})
.collect()
}
pub fn looks_like_pegout(o: &TxOut) -> bool {
op_return_data(&o.script_pubkey)
.is_some_and(|d| String::from_utf8_lossy(without_bom(d)).starts_with("pegout:"))
}
pub fn pegout_marker_data(chain_id: &str, side_txid: &str) -> Result<Vec<u8>> {
let mut out = format!("pegout:{chain_id}:").into_bytes();
let txid = hex::decode(side_txid).map_err(|e| Error::Encoding(e.to_string()))?;
if txid.len() != 32 {
return Err(Error::Encoding("a txid is 32 bytes".into()));
}
out.extend_from_slice(&txid);
Ok(out)
}
pub fn parse_pegout_marker(spk: &Script, chain_id: &str) -> Option<String> {
let d = op_return_data(spk)?;
let rest = d.strip_prefix(format!("pegout:{chain_id}:").as_bytes())?;
(rest.len() == 32).then(|| hex::encode(rest))
}
pub fn checkpoint_data(chain_id: &str, height: u32, hash: &str) -> Result<Vec<u8>> {
let mut out = format!("ckpt:{chain_id}:").into_bytes();
out.extend_from_slice(&height.to_le_bytes());
out.push(b':');
let h = hex::decode(hash).map_err(|e| Error::Encoding(e.to_string()))?;
if h.len() != 32 {
return Err(Error::Encoding("a block hash is 32 bytes".into()));
}
out.extend_from_slice(&h);
if out.len() > 80 {
return Err(Error::Block(format!(
"checkpoint of {} bytes exceeds the 80-byte data limit; the chain id is too long",
out.len()
)));
}
Ok(out)
}
pub fn parse_checkpoint(spk: &Script, chain_id: &str) -> Option<(u32, String)> {
let d = op_return_data(spk)?;
let rest = d.strip_prefix(format!("ckpt:{chain_id}:").as_bytes())?;
if rest.len() != 37 || rest[4] != b':' {
return None;
}
Some((
u32::from_le_bytes([rest[0], rest[1], rest[2], rest[3]]),
hex::encode(&rest[5..]),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn peg_marker_both_forms() {
let script = ScriptBuf::from_hex(&format!("5120{}", "ab".repeat(32))).unwrap();
let raw = peg_marker_data("sidestr:trial", &script);
assert_eq!(raw.len(), 20 + 34);
assert_eq!(
parse_peg_marker(&op_return(&raw), "sidestr:trial"),
Some(script.clone())
);
assert_eq!(parse_peg_marker(&op_return(&raw), "sidestr:other"), None);
let long_id = "sidestr:".to_string() + &"x".repeat(60);
let hexform = peg_marker_data(&long_id, &script);
assert!(hexform.len() > 80 && hexform.ends_with(b"ab"));
let text = format!("pegin:sidestr:trial:{}", script.to_hex_string());
assert_eq!(
parse_peg_marker(&op_return(text.as_bytes()), "sidestr:trial"),
Some(script)
);
}
#[test]
fn claims_and_burns() {
let peg = "b".repeat(64);
assert_eq!(
parse_claim_text(&format!("claim:{peg}:12")),
Some((peg.clone(), 12))
);
assert_eq!(parse_claim_text(&format!("claim:{peg}:123456")), None);
assert_eq!(
parse_claim_text(&format!("claim:{}:0", "B".repeat(64))),
None
);
let parent = format!("5120{}", "e9".repeat(32));
let m = pegout_marker(&parent);
assert!(m.to_hex_string().starts_with("6a4b"));
assert_eq!(parse_pegout(&m), Some(parent));
assert_eq!(parse_pegout(&pegout_marker("00")), None);
assert_eq!(parse_pegout(&pegout_marker(&"ab".repeat(41))), None);
assert_eq!(
parse_pegout(&pegout_marker(&"ab".repeat(40))).map(|s| s.len()),
Some(80)
);
let out = |s: &ScriptBuf| TxOut {
value: bitcoin::Amount::ZERO,
script_pubkey: s.clone(),
};
let bad = ScriptBuf::from_bytes([&[0x6a, 0x09][..], b"pegout:zz"].concat());
assert!(looks_like_pegout(&out(&bad)) && parse_pegout(&bad).is_none());
let long = pegout_marker(&"ab".repeat(40));
assert!(long.to_hex_string().starts_with("6a4c57"));
assert!(looks_like_pegout(&out(&long)) && parse_pegout(&long).is_some());
let bad_long = ScriptBuf::from_bytes(
[
&[0x6a, 0x4c, 0x4c][..],
format!("pegout:{}", "a".repeat(69)).as_bytes(),
]
.concat(),
);
assert!(looks_like_pegout(&out(&bad_long)) && parse_pegout(&bad_long).is_none());
let not_push = ScriptBuf::from_bytes([&[0x6a, 0x4c, 0x0b][..], b"pegout:zz"].concat());
assert!(!looks_like_pegout(&out(¬_push)));
}
#[test]
fn bom_is_dropped_exactly_where_the_reference_text_decodes() {
let bom = |rest: &[u8]| op_return(&[BOM, rest].concat());
let out = |s: &ScriptBuf| TxOut {
value: bitcoin::Amount::ZERO,
script_pubkey: s.clone(),
};
assert_eq!(parse_pegout(&bom(b"pegout:abcd")).as_deref(), Some("abcd"));
let bad = bom(b"pegout:abcde");
assert!(looks_like_pegout(&out(&bad)) && parse_pegout(&bad).is_none());
let twice = op_return(&[BOM, BOM, b"pegout:abcd"].concat());
assert!(!looks_like_pegout(&out(&twice)) && parse_pegout(&twice).is_none());
let peg = "b".repeat(64);
let mut tx = Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::absolute::LockTime::ZERO,
input: vec![],
output: vec![
TxOut {
value: bitcoin::Amount::from_sat(1),
script_pubkey: ScriptBuf::from_bytes(vec![0x51]),
},
out(&bom(format!("claim:{peg}:7").as_bytes())),
],
};
let (claims, errors) = parse_claims(&tx);
assert!(errors.is_empty());
assert_eq!((claims[0].txid.as_str(), claims[0].vout), (peg.as_str(), 7));
tx.output[1] = out(&op_return(
&[BOM, BOM, format!("claim:{peg}:7").as_bytes()].concat(),
));
assert!(parse_claims(&tx).0.is_empty());
let hexform = op_return(&[b"pegin:sidestr:trial:", BOM, b"abcd"].concat());
assert_eq!(
parse_peg_marker(&hexform, "sidestr:trial").map(|s| s.to_hex_string()),
Some("abcd".into())
);
let raw = op_return(&[b"pegin:sidestr:trial:", BOM, b"abc"].concat());
assert_eq!(
parse_peg_marker(&raw, "sidestr:trial").map(|s| s.to_hex_string()),
Some("efbbbf616263".into())
);
let only = op_return(&[b"pegin:sidestr:trial:", BOM].concat());
assert_eq!(
parse_peg_marker(&only, "sidestr:trial").map(|s| s.to_hex_string()),
Some("efbbbf".into())
);
assert_eq!(
record_text(&bom(b"issue:X:0")).as_deref(),
Some("issue:X:0")
);
let d = pegout_marker_data("sidestr:t", &"c".repeat(64)).unwrap();
assert_eq!(parse_pegout_marker(&bom(&d), "sidestr:t"), None);
let ck = checkpoint_data("sidestr:t", 1, &"d".repeat(64)).unwrap();
assert_eq!(parse_checkpoint(&bom(&ck), "sidestr:t"), None);
}
#[test]
fn checked_constructors_and_untruncated_pushes() {
let peg = "a".repeat(64);
assert!(try_claim_marker(&peg, CLAIM_VOUT_MAX).is_ok());
assert!(try_claim_marker(&peg, CLAIM_VOUT_MAX + 1).is_err());
assert!(try_claim_marker(&peg, u32::MAX).is_err());
assert!(try_claim_marker(&"a".repeat(63), 0).is_err());
assert!(try_claim_marker(&"A".repeat(64), 0).is_err());
assert_eq!(try_claim_marker(&peg, 5).unwrap(), claim_marker(&peg, 5));
assert!(try_pegout_marker("5120").is_ok());
assert_eq!(
try_pegout_marker(&"AB".repeat(40)).unwrap(),
pegout_marker(&"ab".repeat(40))
);
assert!(try_pegout_marker("00").is_err());
assert!(try_pegout_marker("abc").is_err());
assert!(try_pegout_marker("zz").is_err());
assert!(try_pegout_marker(&"ab".repeat(41)).is_err());
assert!(try_pegout_marker("").is_err());
let long = pegout_marker(&"ab".repeat(125));
let b = long.as_bytes();
assert_eq!(&b[..4], &[0x6a, 0x4d, 0x01, 0x01]);
assert_eq!(b.len(), 4 + 257);
assert!(op_return_data(&long).is_none() && parse_pegout(&long).is_none());
assert!(long.instructions().all(|i| i.is_ok()));
let claim = claim_marker(&peg, u32::MAX);
assert!(claim.to_hex_string().starts_with("6a4c51"));
assert!(claim.instructions().all(|i| i.is_ok()));
}
#[test]
fn parent_records() {
let txid = "c".repeat(64);
let d = pegout_marker_data("sidestr:pegouttest", &txid).unwrap();
assert!(d.len() <= 80);
assert_eq!(
parse_pegout_marker(&op_return(&d), "sidestr:pegouttest"),
Some(txid)
);
assert_eq!(parse_pegout_marker(&op_return(&d), "sidestr:other"), None);
let ck = checkpoint_data("sidestr:gitmark", 70_000, &"d".repeat(64)).unwrap();
assert_eq!(ck.len(), 21 + 37);
assert_eq!(
parse_checkpoint(&op_return(&ck), "sidestr:gitmark"),
Some((70_000, "d".repeat(64)))
);
assert!(checkpoint_data(&"x".repeat(50), 1, &"d".repeat(64)).is_err());
}
#[test]
fn records() {
assert_eq!(
record_text(&record_script("issue:SHELL:2").unwrap()).as_deref(),
Some("issue:SHELL:2")
);
assert_eq!(
record_text(&record_script(&"x".repeat(200)).unwrap()).as_deref(),
Some("x".repeat(200).as_str())
);
assert!(record_script(&"x".repeat(256)).is_err());
assert_eq!(
record_text(&ScriptBuf::from_bytes(vec![0x6a, 0x4c, 0x02, 0x41, 0x42])),
None
); assert_eq!(
record_text(&ScriptBuf::from_bytes(vec![0x6a, 0x03, 0x41, 0x42])),
None
); assert_eq!(
op_return_data(&ScriptBuf::from_bytes(vec![0x6a, 0x4c])),
None
);
assert_eq!(op_return_data(&ScriptBuf::from_bytes(vec![0x51])), None);
}
}