use std::collections::BTreeSet;
use std::future::Future;
use std::time::Duration;
use chia_protocol::{Bytes32, Coin, CoinSpend};
use clvmr::{Allocator, NodePtr, SExp};
use dig_chainsource_interface::{ChainSourceError, SingletonLineage};
const CREATE_COIN: u8 = 51;
const MAX_CLVM_COST: u64 = 11_000_000_000;
const WALK_DEADLINE: Duration = Duration::from_secs(45);
const MAX_LINEAGE_GENERATIONS: usize = 100_000;
pub(crate) async fn walk_singleton_lineage<F, Fut, X>(
launcher_id: Bytes32,
fetch_spend: F,
extract_child: X,
) -> Result<Option<SingletonLineage>, ChainSourceError>
where
F: Fn(Bytes32) -> Fut,
Fut: Future<Output = Result<Option<CoinSpend>, ChainSourceError>>,
X: Fn(&CoinSpend) -> Result<Option<Bytes32>, ChainSourceError>,
{
walk_singleton_lineage_bounded(
launcher_id,
WALK_DEADLINE,
MAX_LINEAGE_GENERATIONS,
fetch_spend,
extract_child,
)
.await
}
async fn walk_singleton_lineage_bounded<F, Fut, X>(
launcher_id: Bytes32,
deadline: Duration,
max_generations: usize,
fetch_spend: F,
extract_child: X,
) -> Result<Option<SingletonLineage>, ChainSourceError>
where
F: Fn(Bytes32) -> Fut,
Fut: Future<Output = Result<Option<CoinSpend>, ChainSourceError>>,
X: Fn(&CoinSpend) -> Result<Option<Bytes32>, ChainSourceError>,
{
let walk =
walk_singleton_lineage_capped(launcher_id, max_generations, fetch_spend, extract_child);
match tokio::time::timeout(deadline, walk).await {
Ok(result) => result,
Err(_elapsed) => Err(ChainSourceError::Timeout),
}
}
async fn walk_singleton_lineage_capped<F, Fut, X>(
launcher_id: Bytes32,
max_generations: usize,
fetch_spend: F,
extract_child: X,
) -> Result<Option<SingletonLineage>, ChainSourceError>
where
F: Fn(Bytes32) -> Fut,
Fut: Future<Output = Result<Option<CoinSpend>, ChainSourceError>>,
X: Fn(&CoinSpend) -> Result<Option<Bytes32>, ChainSourceError>,
{
let Some(launch_spend) = fetch_spend(launcher_id).await? else {
return Ok(None); };
if launch_spend.coin.coin_id() != launcher_id {
return Err(ChainSourceError::Malformed(
"fetched launcher spend does not match the requested launcher id".to_string(),
));
}
let Some(mut current) = extract_child(&launch_spend)? else {
return Ok(None); };
let mut members: BTreeSet<Bytes32> = BTreeSet::new();
loop {
if members.len() >= max_generations {
return Err(ChainSourceError::Malformed(format!(
"singleton lineage exceeded the maximum of {max_generations} generations"
)));
}
if !members.insert(current) {
return Err(ChainSourceError::Malformed(
"singleton lineage revisited a coin (cycle)".to_string(),
));
}
match fetch_spend(current).await? {
None => return Ok(Some(SingletonLineage::new(current, members))),
Some(spend) => {
if spend.coin.coin_id() != current {
return Err(ChainSourceError::Malformed(
"fetched spend does not match the requested coin id".to_string(),
));
}
match extract_child(&spend)? {
Some(child) => current = child,
None => return Ok(None),
}
}
}
}
}
pub(crate) fn singleton_child_from_spend(
spend: &CoinSpend,
) -> Result<Option<Bytes32>, ChainSourceError> {
let mut allocator = Allocator::new();
let puzzle =
clvmr::serde::node_from_bytes_backrefs(&mut allocator, spend.puzzle_reveal.as_ref())
.map_err(|e| ChainSourceError::Malformed(format!("undecodable puzzle reveal: {e}")))?;
let reveal_hash: [u8; 32] = chia::clvm_utils::tree_hash(&allocator, puzzle).into();
if Bytes32::new(reveal_hash) != spend.coin.puzzle_hash {
return Err(ChainSourceError::Malformed(
"puzzle reveal does not hash to the spent coin's puzzle hash".to_string(),
));
}
let solution = clvmr::serde::node_from_bytes_backrefs(&mut allocator, spend.solution.as_ref())
.map_err(|e| ChainSourceError::Malformed(format!("undecodable solution: {e}")))?;
let dialect = clvmr::ChiaDialect::new(0);
let output = clvmr::run_program(&mut allocator, &dialect, puzzle, solution, MAX_CLVM_COST)
.map_err(|e| ChainSourceError::Malformed(format!("puzzle evaluation failed: {e:?}")))?
.1;
let parent_id = spend.coin.coin_id();
for condition in list_iter(&allocator, output) {
if let Some(child) = create_coin_child(&allocator, condition, parent_id) {
return Ok(Some(child));
}
}
Ok(None)
}
fn create_coin_child(a: &Allocator, condition: NodePtr, parent_id: Bytes32) -> Option<Bytes32> {
let mut args = list_iter(a, condition);
let opcode = args.next()?;
if atom_bytes(a, opcode)? != [CREATE_COIN] {
return None;
}
let puzzle_hash_node = args.next()?;
let amount_node = args.next()?;
let puzzle_hash: [u8; 32] = atom_bytes(a, puzzle_hash_node)?.try_into().ok()?;
let amount = atom_to_u64(&atom_bytes(a, amount_node)?);
if amount.is_multiple_of(2) {
return None;
}
Some(Coin::new(parent_id, Bytes32::new(puzzle_hash), amount).coin_id())
}
fn atom_bytes(a: &Allocator, node: NodePtr) -> Option<Vec<u8>> {
match a.sexp(node) {
SExp::Atom => Some(a.atom(node).as_ref().to_vec()),
SExp::Pair(..) => None,
}
}
fn atom_to_u64(bytes: &[u8]) -> u64 {
bytes
.iter()
.fold(0u64, |acc, &b| acc.wrapping_shl(8).wrapping_add(b as u64))
}
fn list_iter(a: &Allocator, node: NodePtr) -> impl Iterator<Item = NodePtr> + '_ {
let mut cursor = node;
std::iter::from_fn(move || match a.sexp(cursor) {
SExp::Pair(first, rest) => {
cursor = rest;
Some(first)
}
SExp::Atom => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use chia_protocol::{Coin, Program};
fn coin(parent: Bytes32, ph: u8) -> Coin {
Coin::new(parent, Bytes32::new([ph; 32]), 1)
}
fn spend_of(coin: Coin) -> CoinSpend {
CoinSpend::new(coin, Program::from(vec![0x80]), Program::from(vec![0x80]))
}
fn fetcher(
spends: HashMap<Bytes32, CoinSpend>,
) -> impl Fn(Bytes32) -> std::future::Ready<Result<Option<CoinSpend>, ChainSourceError>> {
move |id| std::future::ready(Ok(spends.get(&id).cloned()))
}
fn table_extractor(
children: HashMap<Bytes32, Bytes32>,
) -> impl Fn(&CoinSpend) -> Result<Option<Bytes32>, ChainSourceError> {
move |spend: &CoinSpend| Ok(children.get(&spend.coin.coin_id()).copied())
}
#[allow(clippy::type_complexity)]
fn genuine_lineage() -> (
HashMap<Bytes32, CoinSpend>,
Bytes32,
Vec<Bytes32>,
HashMap<Bytes32, Bytes32>,
) {
let launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = launcher.coin_id();
let eve = coin(launcher_id, 0x10);
let c1 = coin(eve.coin_id(), 0x11);
let tip = coin(c1.coin_id(), 0x12);
let mut spends = HashMap::new();
spends.insert(launcher_id, spend_of(launcher));
spends.insert(eve.coin_id(), spend_of(eve));
spends.insert(c1.coin_id(), spend_of(c1));
let mut children = HashMap::new();
children.insert(launcher_id, eve.coin_id());
children.insert(eve.coin_id(), c1.coin_id());
children.insert(c1.coin_id(), tip.coin_id());
let members = vec![eve.coin_id(), c1.coin_id(), tip.coin_id()];
(spends, launcher_id, members, children)
}
#[tokio::test]
async fn walks_launcher_to_tip_and_collects_every_member() {
let (spends, launcher_id, members, children) = genuine_lineage();
let lineage =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children))
.await
.unwrap()
.expect("a launched singleton");
assert_eq!(lineage.tip(), *members.last().unwrap());
for member in &members {
assert!(
lineage.contains(*member),
"genuine member must be in lineage"
);
}
assert_eq!(lineage.len(), members.len());
}
#[tokio::test]
async fn fabricated_coin_is_not_a_lineage_member() {
let (spends, launcher_id, members, children) = genuine_lineage();
let lineage =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children))
.await
.unwrap()
.unwrap();
let fabricated = coin(Bytes32::new([0xEE; 32]), 0x12).coin_id();
assert!(!lineage.contains(fabricated));
assert!(!lineage.contains(launcher_id));
let real_tip = *members.last().unwrap();
let forked_tip = Coin::new(Bytes32::new([0x99; 32]), Bytes32::new([0x12; 32]), 1).coin_id();
assert_ne!(forked_tip, real_tip);
assert!(!lineage.contains(forked_tip));
}
#[tokio::test]
async fn unlaunched_launcher_returns_none() {
let result = walk_singleton_lineage(
Bytes32::new([0x07; 32]),
fetcher(HashMap::new()),
table_extractor(HashMap::new()),
)
.await
.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn melted_singleton_returns_none() {
let launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = launcher.coin_id();
let eve = coin(launcher_id, 0x10);
let mut spends = HashMap::new();
spends.insert(launcher_id, spend_of(launcher));
spends.insert(eve.coin_id(), spend_of(eve));
let mut children = HashMap::new();
children.insert(launcher_id, eve.coin_id());
let result =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children))
.await
.unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn source_error_fails_closed_not_none() {
let fetch = |_id: Bytes32| std::future::ready(Err(ChainSourceError::Timeout));
let result = walk_singleton_lineage(
Bytes32::new([0x07; 32]),
fetch,
table_extractor(HashMap::new()),
)
.await;
assert_eq!(result, Err(ChainSourceError::Timeout));
}
fn create_coin_spend(child_ph: [u8; 32], amount: u8) -> (CoinSpend, Bytes32) {
let mut a = Allocator::new();
let op = a.new_atom(&[CREATE_COIN]).unwrap();
let ph_atom = a.new_atom(&child_ph).unwrap();
let amt_atom = a.new_atom(&[amount]).unwrap();
let nil = a.nil();
let arg2 = a.new_pair(amt_atom, nil).unwrap();
let arg1 = a.new_pair(ph_atom, arg2).unwrap();
let condition = a.new_pair(op, arg1).unwrap();
let conditions = a.new_pair(condition, nil).unwrap();
let quote = a.new_atom(&[1]).unwrap();
let puzzle = a.new_pair(quote, conditions).unwrap();
let puzzle_bytes = clvmr::serde::node_to_bytes(&a, puzzle).unwrap();
let solution_bytes = clvmr::serde::node_to_bytes(&a, nil).unwrap();
let puzzle_hash: [u8; 32] = chia::clvm_utils::tree_hash(&a, puzzle).into();
let coin = Coin::new(Bytes32::new([0xAB; 32]), Bytes32::new(puzzle_hash), 1);
let parent_id = coin.coin_id();
let spend = CoinSpend::new(
coin,
Program::from(puzzle_bytes),
Program::from(solution_bytes),
);
(spend, parent_id)
}
#[test]
fn extractor_returns_odd_create_coin_child() {
let child_ph = [0x55u8; 32];
let (spend, parent_id) = create_coin_spend(child_ph, 3); let child = singleton_child_from_spend(&spend).unwrap();
let expected = Coin::new(parent_id, Bytes32::new(child_ph), 3).coin_id();
assert_eq!(child, Some(expected));
}
#[test]
fn extractor_ignores_even_amount_create_coin() {
let (spend, _) = create_coin_spend([0x55u8; 32], 2); assert_eq!(singleton_child_from_spend(&spend).unwrap(), None);
}
#[test]
fn extractor_rejects_unauthenticated_reveal() {
let (mut spend, _) = create_coin_spend([0x55u8; 32], 3);
spend.coin = Coin::new(
spend.coin.parent_coin_info,
Bytes32::new([0x00; 32]), spend.coin.amount,
);
assert!(matches!(
singleton_child_from_spend(&spend),
Err(ChainSourceError::Malformed(_))
));
}
#[test]
fn extractor_rejects_undecodable_reveal() {
let coin = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let spend = CoinSpend::new(coin, Program::from(vec![0xff]), Program::from(vec![0x80]));
assert!(matches!(
singleton_child_from_spend(&spend),
Err(ChainSourceError::Malformed(_))
));
}
#[tokio::test]
async fn cyclic_lineage_fails_closed() {
let a_coin = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = a_coin.coin_id();
let b = coin(launcher_id, 0x10);
let mut spends = HashMap::new();
spends.insert(launcher_id, spend_of(a_coin));
spends.insert(b.coin_id(), spend_of(b));
let mut children = HashMap::new();
children.insert(launcher_id, b.coin_id());
children.insert(b.coin_id(), launcher_id);
let result =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children)).await;
assert!(matches!(result, Err(ChainSourceError::Malformed(_))));
}
#[tokio::test]
async fn spend_of_wrong_coin_fails_closed() {
let launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = launcher.coin_id();
let eve = coin(launcher_id, 0x10);
let wrong = coin(Bytes32::new([0xDD; 32]), 0x20);
assert_ne!(wrong.coin_id(), eve.coin_id());
let mut spends = HashMap::new();
spends.insert(launcher_id, spend_of(launcher));
spends.insert(eve.coin_id(), spend_of(wrong));
let mut children = HashMap::new();
children.insert(launcher_id, eve.coin_id());
children.insert(wrong.coin_id(), coin(wrong.coin_id(), 0x21).coin_id());
let result =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children)).await;
assert!(
matches!(result, Err(ChainSourceError::Malformed(_))),
"a spend of the wrong coin must fail closed, got {result:?}"
);
}
#[tokio::test]
async fn unbounded_non_repeating_lineage_fails_closed_via_depth_cap() {
use std::cell::RefCell;
use std::rc::Rc;
let launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = launcher.coin_id();
let eve = coin(launcher_id, 0x10);
let known: Rc<RefCell<HashMap<Bytes32, Coin>>> = Rc::new(RefCell::new(HashMap::new()));
known.borrow_mut().insert(launcher_id, launcher);
known.borrow_mut().insert(eve.coin_id(), eve);
let fetch_known = known.clone();
let fetch = move |id: Bytes32| {
let spend = fetch_known.borrow().get(&id).cloned().map(spend_of);
std::future::ready(Ok(spend))
};
let extract_known = known.clone();
let eve_id = eve.coin_id();
let extract = move |spend: &CoinSpend| {
let parent = spend.coin.coin_id();
if parent == launcher_id {
return Ok(Some(eve_id));
}
let child = Coin::new(parent, Bytes32::new([0x33; 32]), 1);
extract_known.borrow_mut().insert(child.coin_id(), child);
Ok(Some(child.coin_id()))
};
let cap = 32;
let result = walk_singleton_lineage_capped(launcher_id, cap, fetch, extract).await;
assert!(
matches!(result, Err(ChainSourceError::Malformed(_))),
"an unbounded non-repeating lineage must fail closed at the depth cap, got {result:?}"
);
}
#[tokio::test]
async fn walk_exceeding_wall_clock_deadline_returns_timeout() {
let launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = launcher.coin_id();
let fetch = |id: Bytes32| async move {
tokio::time::sleep(Duration::from_millis(200)).await;
Ok::<_, ChainSourceError>(Some(spend_of(coin(id, 0x02))))
};
let deadline = Duration::from_millis(20);
let result = walk_singleton_lineage_bounded(
launcher_id,
deadline,
100,
fetch,
table_extractor(HashMap::new()),
)
.await;
assert_eq!(result, Err(ChainSourceError::Timeout));
}
#[tokio::test]
async fn launcher_spend_mismatch_fails_closed() {
let real_launcher = Coin::new(Bytes32::new([0x01; 32]), Bytes32::new([0x02; 32]), 1);
let launcher_id = real_launcher.coin_id();
let other = Coin::new(Bytes32::new([0xCC; 32]), Bytes32::new([0x03; 32]), 1);
assert_ne!(other.coin_id(), launcher_id);
let mut spends = HashMap::new();
spends.insert(launcher_id, spend_of(other));
let mut children = HashMap::new();
children.insert(other.coin_id(), coin(other.coin_id(), 0x10).coin_id());
let result =
walk_singleton_lineage(launcher_id, fetcher(spends), table_extractor(children)).await;
assert!(
matches!(result, Err(ChainSourceError::Malformed(_))),
"a launcher spend of the wrong coin must fail closed, got {result:?}"
);
}
}