use crate::cert::{HandleSubtree, KeyHash, NumsSubtree, NumsValue, SpacesSubtree, SpacesValue};
use crate::msg::{ChainProof, Message};
use crate::{Veritas, compute_root_id, deserialize_hash, serialize_hash};
use serde::{Deserialize, Serialize};
use spacedb::path::{BitLength, Direction, Path, PathUtils};
use spacedb::subtree::{SubTreeNode, ValueOrHash};
use spacedb::{Hash, NodeHasher, Sha256Hasher};
use spaces_nums::num_id::NumId;
use spaces_nums::snumeric::SNumeric;
use spaces_nums::{Commitment, CommitmentKey, CommitmentTipKey};
use spaces_protocol::bitcoin::hashes::Hash as _;
use spaces_protocol::sname::SName;
use std::fmt;
#[derive(Debug, Clone)]
pub enum InspectError {
NoAnchorAtHeight(u32),
AnchorOutOfRange { anchor: u32, oldest: u32, tip: u32 },
SpaceNotFound(String),
IncompleteProof(&'static str),
}
impl fmt::Display for InspectError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoAnchorAtHeight(h) => write!(f, "no anchor at height {h}"),
Self::AnchorOutOfRange {
anchor,
oldest,
tip,
} => write!(f, "anchor {anchor} outside range [{oldest}, {tip}]"),
Self::SpaceNotFound(s) => write!(f, "space not found in proof: {s}"),
Self::IncompleteProof(why) => write!(f, "incomplete proof: {why}"),
}
}
}
impl std::error::Error for InspectError {}
#[derive(Serialize, Deserialize)]
pub struct InspectReport {
pub anchor: AnchorInfo,
pub zones: Vec<ZoneInspect>,
}
#[derive(Serialize, Deserialize)]
pub struct AnchorInfo {
pub block_height: u32,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub block_hash: Hash,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub spaces_root: Hash,
#[serde(
serialize_with = "crate::serialize_option_hash",
deserialize_with = "crate::deserialize_option_hash"
)]
pub nums_root: Option<Hash>,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub anchor_hash: Hash,
}
#[derive(Serialize, Deserialize, Copy, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ZoneKind {
Space,
Numeric,
Handle,
}
#[derive(Serialize, Deserialize)]
pub struct ZoneInspect {
pub handle: String,
pub kind: ZoneKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sovereignty: Option<crate::SovereigntyState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub receipt: Option<ReceiptInfo>,
#[serde(skip_serializing_if = "RecordsInfo::is_empty")]
pub records: RecordsInfo,
pub paths: Vec<ProofPath>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ReceiptInfo {
pub kind: String,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub initial_root: Hash,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub final_root: Hash,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub rolling_hash: Hash,
pub policy_ok: bool,
}
#[derive(Serialize, Deserialize, Clone, Default)]
pub struct RecordsInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub owner: Option<RecordSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegate: Option<RecordSummary>,
}
impl RecordsInfo {
fn is_empty(&self) -> bool {
self.owner.is_none() && self.delegate.is_none()
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RecordSummary {
pub seq: u64,
pub flags: u8,
pub canonical: String,
pub handle: String,
pub signature: SigStatus,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SigStatus {
Valid,
Invalid,
Unchecked,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum TreeKind {
SpacesRoot,
NumsRoot,
HandlesRoot,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug)]
#[serde(rename_all = "snake_case")]
pub enum ProofPurpose {
SpaceUtxo,
NumericUtxo,
CommitmentTip,
Commitment,
DelegateLookup,
KeyRotation,
HandleInclusion,
HandleExclusion,
}
#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Resolution {
Included,
ProvablyExcluded,
Incomplete,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct ProofPath {
pub tree: TreeKind,
pub purpose: ProofPurpose,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub root: Hash,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub key: Hash,
pub resolution: Resolution,
#[serde(skip_serializing_if = "Option::is_none")]
pub provides_root: Option<TreeKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub commitment: Option<CommitmentMeta>,
pub leaf: Option<LeafInfo>,
pub steps: Vec<PathStep>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CommitmentMeta {
pub block_height: u32,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub state_root: Hash,
#[serde(
skip_serializing_if = "Option::is_none",
serialize_with = "crate::serialize_option_hash",
deserialize_with = "crate::deserialize_option_hash"
)]
pub prev_root: Option<Hash>,
pub genesis: bool,
pub directly_proven: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct LeafInfo {
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub key: Hash,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub value_hash: Hash,
pub value_kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub spent: Option<bool>,
pub matched: bool,
}
#[derive(Serialize, Deserialize, Copy, Clone)]
#[serde(rename_all = "lowercase")]
pub enum Dir {
Left,
Right,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct PathStep {
pub depth: u32,
pub prefix_bit_len: u32,
#[serde(with = "hex_bytes")]
pub prefix: Vec<u8>,
pub direction: Dir,
#[serde(
serialize_with = "serialize_hash",
deserialize_with = "deserialize_hash"
)]
pub sibling_hash: Hash,
}
fn hash_node<H: NodeHasher>(node: &SubTreeNode) -> Result<Hash, InspectError> {
match node {
SubTreeNode::Leaf { key, value_or_hash } => {
let v_hash = match value_or_hash {
ValueOrHash::Value(v) => H::hash(v),
ValueOrHash::Hash(h) => *h,
};
Ok(H::hash_leaf(&key.0, &v_hash))
}
SubTreeNode::Internal {
prefix,
left,
right,
} => {
let l = hash_node::<H>(left)?;
let r = hash_node::<H>(right)?;
Ok(H::hash_internal(prefix.as_bytes(), &l, &r))
}
SubTreeNode::Hash(h) => Ok(*h),
SubTreeNode::None => Err(InspectError::IncompleteProof("hash empty")),
}
}
fn walk_path<H: NodeHasher>(
root: &SubTreeNode,
key: &Hash,
) -> Result<(Vec<PathStep>, Option<LeafInfo>, Resolution), InspectError> {
let key_path = Path(key);
let mut steps = Vec::new();
let mut node = root;
let mut depth: usize = 0;
loop {
match node {
SubTreeNode::Leaf {
key: leaf_key,
value_or_hash,
} => {
let matched = leaf_key.0 == *key;
let (value_hash, value_kind, spent) = match value_or_hash {
ValueOrHash::Value(v) => (
H::hash(v),
classify_value(v.as_slice()),
decode_spent(v.as_slice()),
),
ValueOrHash::Hash(h) => (*h, "Unknown", None),
};
let resolution = if matched {
Resolution::Included
} else {
Resolution::ProvablyExcluded
};
return Ok((
steps,
Some(LeafInfo {
key: leaf_key.0,
value_hash,
value_kind: value_kind.to_string(),
spent,
matched,
}),
resolution,
));
}
SubTreeNode::Internal {
prefix,
left,
right,
} => {
if key_path.split_point(depth, *prefix).is_some() {
return Ok((steps, None, Resolution::ProvablyExcluded));
}
depth += prefix.bit_len();
let dir = key_path.direction(depth);
let (chosen, sibling, dir_enum) = match dir {
Direction::Left => (left.as_ref(), right.as_ref(), Dir::Left),
Direction::Right => (right.as_ref(), left.as_ref(), Dir::Right),
};
let prefix_bit_len = prefix.bit_len();
let as_bytes = prefix.as_bytes();
let prefix_bytes = if as_bytes.len() > 1 {
as_bytes[1..].to_vec()
} else {
Vec::new()
};
steps.push(PathStep {
depth: depth as u32 - prefix_bit_len as u32,
prefix_bit_len: prefix_bit_len as u32,
prefix: prefix_bytes,
direction: dir_enum,
sibling_hash: hash_node::<H>(sibling)?,
});
depth += 1;
node = chosen;
}
SubTreeNode::Hash(_) => {
return Ok((steps, None, Resolution::Incomplete));
}
SubTreeNode::None => {
return Ok((steps, None, Resolution::ProvablyExcluded));
}
}
}
}
fn decode_spent(bytes: &[u8]) -> Option<bool> {
borsh::from_slice::<spaces_nums::NumOut>(bytes)
.ok()
.map(|n| n.spent)
}
fn receipt_info(receipt: &risc0_zkvm::Receipt) -> Option<ReceiptInfo> {
let zkc = receipt
.journal
.decode::<libveritas_zk::guest::Commitment>()
.ok()?;
let policy_ok = zkc.policy_fold == crate::constants::FOLD_ID
&& zkc.policy_step == crate::constants::STEP_ID;
let kind = match zkc.kind {
libveritas_zk::guest::CommitmentKind::Fold => "fold",
libveritas_zk::guest::CommitmentKind::Step => "step",
};
Some(ReceiptInfo {
kind: kind.to_string(),
initial_root: zkc.initial_root,
final_root: zkc.final_root,
rolling_hash: zkc.rolling_hash,
policy_ok,
})
}
fn record_summary(
records: &sip7::RecordSet,
signer_spk: Option<&spaces_protocol::bitcoin::ScriptBuf>,
canonical: &SName,
) -> Option<RecordSummary> {
let sig = records.sig()?;
let signature = match signer_spk {
Some(spk) if crate::msg::verify_records(records, spk, canonical).is_ok() => {
SigStatus::Valid
}
Some(_) => SigStatus::Invalid,
None => SigStatus::Unchecked,
};
Some(RecordSummary {
seq: records.seq().unwrap_or(0),
flags: sig.flags,
canonical: sig.canonical.to_string(),
handle: sig.handle.to_string(),
signature,
})
}
fn classify_value(bytes: &[u8]) -> &'static str {
if bytes.len() == 32 {
return "CommitmentTip";
}
if borsh::from_slice::<spaces_protocol::SpaceOut>(bytes).is_ok() {
return "SpaceOut";
}
if borsh::from_slice::<spaces_nums::NumOut>(bytes).is_ok() {
return "NumOut";
}
if borsh::from_slice::<Commitment>(bytes).is_ok() {
return "Commitment";
}
if crate::cert::HandleOut::from_slice(bytes).is_ok() {
return "HandleOut";
}
"Unknown"
}
fn make_path(
tree: TreeKind,
purpose: ProofPurpose,
provides_root: Option<TreeKind>,
root: Hash,
subtree_root: &SubTreeNode,
key: Hash,
) -> Result<ProofPath, InspectError> {
let (steps, leaf, resolution) = walk_path::<Sha256Hasher>(subtree_root, &key)?;
Ok(ProofPath {
tree,
purpose,
root,
key,
resolution,
provides_root,
commitment: None,
leaf,
steps,
})
}
fn commitment_path(
chain: &ChainProof,
space: &spaces_protocol::slabel::SLabel,
commitment_root: Hash,
nums_root: Hash,
receipt_final_root: Option<Hash>,
) -> Result<ProofPath, InspectError> {
let key: Hash = CommitmentKey::new::<KeyHash>(space, commitment_root).into();
let mut path = make_path(
TreeKind::NumsRoot,
ProofPurpose::Commitment,
Some(TreeKind::HandlesRoot),
nums_root,
&chain.nums.0.root,
key,
)?;
if let Ok(Some(c)) = chain.nums.find_commitment(space, commitment_root) {
path.commitment = Some(CommitmentMeta {
block_height: c.block_height,
state_root: c.state_root,
prev_root: c.prev_root,
genesis: c.prev_root.is_none(),
directly_proven: receipt_final_root == Some(c.state_root),
});
}
Ok(path)
}
fn find_space_key(
subtree: &SpacesSubtree,
space: &spaces_protocol::slabel::SLabel,
) -> Option<Hash> {
for (k, v) in subtree.iter() {
if let SpacesValue::UTXO(utxo) = v {
if utxo
.space
.as_ref()
.is_some_and(|s| s.name.as_ref() == space.as_ref())
{
return Some(k);
}
}
}
None
}
fn find_num_key_by_id(subtree: &NumsSubtree, target: NumId) -> Option<Hash> {
for (k, v) in subtree.iter() {
if let NumsValue::UTXO(numout) = v {
if numout.num.id == target {
return Some(k);
}
}
}
None
}
fn find_num_key_by_numeric(subtree: &NumsSubtree, target: &SNumeric) -> Option<Hash> {
for (k, v) in subtree.iter() {
if let NumsValue::UTXO(numout) = v {
if &numout.num.name == target {
return Some(k);
}
}
}
None
}
fn parent_script_pubkey(
chain: &ChainProof,
space: &spaces_protocol::slabel::SLabel,
) -> Option<spaces_protocol::bitcoin::ScriptBuf> {
if space.is_numeric() {
let snum: SNumeric = space.clone().try_into().ok()?;
chain
.nums
.find_numeric(&snum)
.ok()
.flatten()
.map(|n| n.script_pubkey)
} else {
chain.spaces.find_space(space).map(|s| s.script_pubkey)
}
}
struct ParentPaths {
identity: Vec<ProofPath>,
tip: Vec<ProofPath>,
}
impl ParentPaths {
fn all(&self) -> Vec<ProofPath> {
let mut v = self.identity.clone();
v.extend(self.tip.iter().cloned());
v
}
}
fn paths_for_parent(
chain: &ChainProof,
space: &spaces_protocol::slabel::SLabel,
spaces_root: Hash,
nums_root: Option<Hash>,
receipt_final_root: Option<Hash>,
) -> Result<ParentPaths, InspectError> {
let mut identity = Vec::new();
let mut tip = Vec::new();
if space.is_numeric() {
let nums_root_h = nums_root.ok_or(InspectError::IncompleteProof(
"nums root missing for numeric",
))?;
let snum: SNumeric = space.clone().try_into().expect("numeric");
if let Some(key) = find_num_key_by_numeric(&chain.nums, &snum) {
identity.push(make_path(
TreeKind::NumsRoot,
ProofPurpose::NumericUtxo,
None,
nums_root_h,
&chain.nums.0.root,
key,
)?);
}
} else {
if let Some(key) = find_space_key(&chain.spaces, space) {
identity.push(make_path(
TreeKind::SpacesRoot,
ProofPurpose::SpaceUtxo,
None,
spaces_root,
&chain.spaces.0.root,
key,
)?);
}
}
if let Some(nums_root_h) = nums_root {
if let Some(spk) = parent_script_pubkey(chain, space) {
let num_id = NumId::from_spk::<KeyHash>(spk);
if let Some(key) = find_num_key_by_id(&chain.nums, num_id) {
identity.push(make_path(
TreeKind::NumsRoot,
ProofPurpose::DelegateLookup,
None,
nums_root_h,
&chain.nums.0.root,
key,
)?);
}
}
}
if let Some(nums_root_h) = nums_root {
let tip_key: Hash = CommitmentTipKey::from_slabel::<KeyHash>(space).into();
let tip_root = chain.nums.get_latest_commitment_root(space).ok().flatten();
tip.push(make_path(
TreeKind::NumsRoot,
ProofPurpose::CommitmentTip,
None,
nums_root_h,
&chain.nums.0.root,
tip_key,
)?);
if let Some(root) = tip_root {
tip.push(commitment_path(
chain,
space,
root,
nums_root_h,
receipt_final_root,
)?);
}
}
Ok(ParentPaths { identity, tip })
}
fn handle_path(
handles_root: Hash,
epoch_tree: &HandleSubtree,
name: &spaces_protocol::sname::Subname,
matched_kind: ProofPurpose,
) -> Result<ProofPath, InspectError> {
let key = Sha256Hasher::hash(name.as_slabel().as_ref());
make_path(
TreeKind::HandlesRoot,
matched_kind,
None,
handles_root,
&epoch_tree.0.root,
key,
)
}
pub fn inspect(veritas: &Veritas, msg: &Message) -> Result<InspectReport, InspectError> {
let height = msg.chain.anchor.height;
let anchor = veritas
.find_anchor(height)
.ok_or(InspectError::NoAnchorAtHeight(height))?;
let anchor_info = AnchorInfo {
block_height: anchor.block.height,
block_hash: anchor.block.hash.to_byte_array(),
spaces_root: anchor.spaces_root,
nums_root: anchor.nums_root,
anchor_hash: compute_root_id(anchor),
};
let mut zones = Vec::new();
for bundle in &msg.spaces {
let space = &bundle.subject;
let parent_handle = SName::from_space(space);
let receipt = bundle.receipt.as_ref().and_then(receipt_info);
let receipt_final_root = receipt.as_ref().map(|r| r.final_root);
let parent_paths = paths_for_parent(
&msg.chain,
space,
anchor.spaces_root,
anchor.nums_root,
receipt_final_root,
)?;
let owner_spk = parent_script_pubkey(&msg.chain, space);
let delegate_spk = owner_spk
.as_ref()
.and_then(|spk| msg.chain.nums.find_num(spk).ok().flatten())
.map(|n| n.script_pubkey);
zones.push(ZoneInspect {
handle: parent_handle.to_string(),
kind: if space.is_numeric() {
ZoneKind::Numeric
} else {
ZoneKind::Space
},
parent: None,
sovereignty: Some(crate::SovereigntyState::Sovereign),
receipt: receipt.clone(),
records: RecordsInfo {
owner: bundle
.records
.as_ref()
.and_then(|r| record_summary(r, owner_spk.as_ref(), &parent_handle)),
delegate: bundle
.delegate_records
.as_ref()
.and_then(|r| record_summary(r, delegate_spk.as_ref(), &parent_handle)),
},
paths: parent_paths.all(),
});
for epoch in &bundle.epochs {
let handles_root = match epoch.tree.compute_root() {
Ok(r) => r,
Err(_) => continue,
};
let epoch_commitment_path = if epoch.tree.0.is_empty() {
None
} else if let Some(nums_root_h) = anchor.nums_root {
Some(commitment_path(
&msg.chain,
space,
handles_root,
nums_root_h,
receipt_final_root,
)?)
} else {
None
};
let epoch_height = if epoch.tree.0.is_empty() {
None
} else {
msg.chain
.nums
.find_commitment(space, handles_root)
.ok()
.flatten()
.map(|c| c.block_height)
};
for h in &epoch.handles {
let purpose = if h.signature.is_some() {
ProofPurpose::HandleExclusion
} else {
ProofPurpose::HandleInclusion
};
let mut paths = parent_paths.identity.clone();
if let Some(path) = &epoch_commitment_path {
paths.push(path.clone());
}
if let Ok(path) = handle_path(handles_root, &epoch.tree, &h.name, purpose) {
paths.push(path);
}
if h.signature.is_none() {
if let Some(nums_root_h) = anchor.nums_root {
let num_id = NumId::from_spk::<KeyHash>(h.genesis_spk.clone());
if let Some(key) = find_num_key_by_id(&msg.chain.nums, num_id) {
paths.push(make_path(
TreeKind::NumsRoot,
ProofPurpose::KeyRotation,
None,
nums_root_h,
&msg.chain.nums.0.root,
key,
)?);
}
}
}
let subject = match spaces_protocol::sname::SName::join(&h.name, space) {
Ok(s) => s,
Err(_) => continue,
};
let sovereignty = if h.signature.is_some() || epoch.tree.0.is_empty() {
crate::SovereigntyState::Dependent
} else {
epoch_height
.map(|hgt| veritas.sovereignty_for(hgt))
.unwrap_or(crate::SovereigntyState::Pending)
};
let record_spk = if h.signature.is_some() {
Some(h.genesis_spk.clone())
} else {
msg.chain
.nums
.find_num(&h.genesis_spk)
.ok()
.flatten()
.map(|n| n.script_pubkey)
.or_else(|| Some(h.genesis_spk.clone()))
};
zones.push(ZoneInspect {
handle: subject.to_string(),
kind: ZoneKind::Handle,
parent: Some(parent_handle.to_string()),
sovereignty: Some(sovereignty),
receipt: None,
records: RecordsInfo {
owner: h
.records
.as_ref()
.and_then(|r| record_summary(r, record_spk.as_ref(), &subject)),
delegate: None,
},
paths,
});
}
}
}
Ok(InspectReport {
anchor: anchor_info,
zones,
})
}
mod hex_bytes {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(bytes: &Vec<u8>, serializer: S) -> Result<S::Ok, S::Error> {
if serializer.is_human_readable() {
serializer.serialize_str(&hex::encode(bytes))
} else {
bytes.serialize(serializer)
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
if deserializer.is_human_readable() {
let s = String::deserialize(deserializer)?;
hex::decode(&s).map_err(serde::de::Error::custom)
} else {
Vec::<u8>::deserialize(deserializer)
}
}
}