use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::registry::{Registry, RegistryEntry};
pub const CHECKPOINT_SCHEMA: &str = "vela.registry_checkpoint.v0.1";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegistryCheckpoint {
pub schema: String,
pub checkpoint_id: String,
pub hub_id: String,
pub sequence: u64,
pub entry_count: u64,
pub registry_root: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_checkpoint: Option<String>,
pub signer_pubkey: String,
pub signature: String,
pub created_at: String,
}
#[derive(Debug, Clone)]
pub struct CheckpointDraft {
pub hub_id: String,
pub sequence: u64,
pub previous_checkpoint: Option<String>,
pub created_at: String,
}
pub fn compute_registry_root(registry: &Registry) -> Result<String, String> {
let mut entries: Vec<&RegistryEntry> = registry.entries.iter().collect();
entries.sort_by(|a, b| a.vfr_id.cmp(&b.vfr_id));
let summary: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"vfr_id": e.vfr_id,
"latest_snapshot_hash": e.latest_snapshot_hash,
"latest_event_log_hash": e.latest_event_log_hash,
"owner_pubkey": e.owner_pubkey,
"signature": e.signature,
})
})
.collect();
let bytes = crate::canonical::to_canonical_bytes(&summary)
.map_err(|e| format!("canonicalize registry summary: {e}"))?;
let digest = Sha256::digest(&bytes);
Ok(format!("sha256:{}", hex::encode(digest)))
}
impl RegistryCheckpoint {
pub fn build(
registry: &Registry,
draft: CheckpointDraft,
signing_key: &ed25519_dalek::SigningKey,
) -> Result<Self, String> {
let root = compute_registry_root(registry)?;
let mut checkpoint = RegistryCheckpoint {
schema: CHECKPOINT_SCHEMA.to_string(),
checkpoint_id: String::new(),
hub_id: draft.hub_id,
sequence: draft.sequence,
entry_count: registry.entries.len() as u64,
registry_root: root,
previous_checkpoint: draft.previous_checkpoint,
signer_pubkey: hex::encode(signing_key.verifying_key().to_bytes()),
signature: String::new(),
created_at: draft.created_at,
};
let preimage = checkpoint.preimage_bytes()?;
use ed25519_dalek::Signer;
let sig = signing_key.sign(&preimage);
checkpoint.signature = hex::encode(sig.to_bytes());
checkpoint.checkpoint_id = checkpoint.derive_id()?;
Ok(checkpoint)
}
pub fn preimage_bytes(&self) -> Result<Vec<u8>, String> {
let mut preimage = self.clone();
preimage.signature = String::new();
preimage.checkpoint_id = String::new();
crate::canonical::to_canonical_bytes(&preimage)
.map_err(|e| format!("canonicalize checkpoint preimage: {e}"))
}
pub fn derive_id(&self) -> Result<String, String> {
let mut preimage = self.clone();
preimage.checkpoint_id = String::new();
let bytes = crate::canonical::to_canonical_bytes(&preimage)
.map_err(|e| format!("canonicalize checkpoint id preimage: {e}"))?;
let digest = Sha256::digest(&bytes);
Ok(format!("vrc_{}", &hex::encode(digest)[..16]))
}
pub fn verify(&self, registry: &Registry) -> Result<(), String> {
if self.schema != CHECKPOINT_SCHEMA {
return Err(format!(
"checkpoint.schema must be `{CHECKPOINT_SCHEMA}`, got `{}`",
self.schema
));
}
let derived_id = self.derive_id()?;
if derived_id != self.checkpoint_id {
return Err(format!(
"checkpoint_id mismatch: stored `{}`, derived `{}`",
self.checkpoint_id, derived_id
));
}
let derived_root = compute_registry_root(registry)?;
if derived_root != self.registry_root {
return Err(format!(
"registry_root mismatch: checkpoint claims `{}`, registry hashes to `{}`",
self.registry_root, derived_root
));
}
if self.entry_count != registry.entries.len() as u64 {
return Err(format!(
"entry_count mismatch: checkpoint claims {}, registry has {}",
self.entry_count,
registry.entries.len()
));
}
let pk_bytes =
hex::decode(&self.signer_pubkey).map_err(|e| format!("signer_pubkey not hex: {e}"))?;
if pk_bytes.len() != 32 {
return Err(format!(
"signer_pubkey must be 32 bytes (got {})",
pk_bytes.len()
));
}
let pk = ed25519_dalek::VerifyingKey::from_bytes(
pk_bytes
.as_slice()
.try_into()
.map_err(|e| format!("signer_pubkey: {e}"))?,
)
.map_err(|e| format!("signer_pubkey malformed: {e}"))?;
let sig_bytes =
hex::decode(&self.signature).map_err(|e| format!("signature not hex: {e}"))?;
if sig_bytes.len() != 64 {
return Err(format!(
"signature must be 64 bytes (got {})",
sig_bytes.len()
));
}
let sig = ed25519_dalek::Signature::from_bytes(
sig_bytes
.as_slice()
.try_into()
.map_err(|e| format!("signature: {e}"))?,
);
let preimage = self.preimage_bytes()?;
use ed25519_dalek::Verifier;
pk.verify(&preimage, &sig)
.map_err(|e| format!("checkpoint signature does not verify: {e}"))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::{ENTRY_SCHEMA, Registry, RegistryEntry};
fn make_entry(vfr: &str) -> RegistryEntry {
RegistryEntry {
schema: ENTRY_SCHEMA.to_string(),
vfr_id: vfr.to_string(),
name: format!("{vfr}-name"),
owner_actor_id: "owner".to_string(),
owner_pubkey: "0".repeat(64),
latest_snapshot_hash: format!("snap-{vfr}"),
latest_event_log_hash: format!("log-{vfr}"),
network_locator: format!("file:///{vfr}"),
signed_publish_at: "2026-05-11T00:00:00+00:00".to_string(),
signature: "f".repeat(128),
}
}
fn make_registry(vfrs: &[&str]) -> Registry {
Registry {
schema: "vela.registry.v0.1".to_string(),
entries: vfrs.iter().map(|v| make_entry(v)).collect(),
}
}
fn make_key() -> ed25519_dalek::SigningKey {
use rand::rngs::OsRng;
ed25519_dalek::SigningKey::generate(&mut OsRng)
}
#[test]
fn registry_root_deterministic_over_same_state() {
let r = make_registry(&["vfr_a", "vfr_b"]);
let a = compute_registry_root(&r).unwrap();
let b = compute_registry_root(&r).unwrap();
assert_eq!(a, b);
}
#[test]
fn registry_root_independent_of_entry_order() {
let r1 = make_registry(&["vfr_a", "vfr_b", "vfr_c"]);
let r2 = make_registry(&["vfr_c", "vfr_a", "vfr_b"]);
let a = compute_registry_root(&r1).unwrap();
let b = compute_registry_root(&r2).unwrap();
assert_eq!(a, b);
}
#[test]
fn registry_root_changes_with_entry_set() {
let r1 = make_registry(&["vfr_a", "vfr_b"]);
let r2 = make_registry(&["vfr_a", "vfr_c"]);
let a = compute_registry_root(&r1).unwrap();
let b = compute_registry_root(&r2).unwrap();
assert_ne!(a, b);
}
#[test]
fn checkpoint_roundtrips() {
let r = make_registry(&["vfr_a", "vfr_b"]);
let sk = make_key();
let cp = RegistryCheckpoint::build(
&r,
CheckpointDraft {
hub_id: "hub:test".to_string(),
sequence: 1,
previous_checkpoint: None,
created_at: "2026-05-11T00:00:00+00:00".to_string(),
},
&sk,
)
.unwrap();
assert!(cp.checkpoint_id.starts_with("vrc_"));
assert_eq!(cp.entry_count, 2);
cp.verify(&r).unwrap();
}
#[test]
fn checkpoint_fails_against_tampered_registry() {
let r1 = make_registry(&["vfr_a", "vfr_b"]);
let r2 = make_registry(&["vfr_a", "vfr_c"]);
let sk = make_key();
let cp = RegistryCheckpoint::build(
&r1,
CheckpointDraft {
hub_id: "hub:test".to_string(),
sequence: 1,
previous_checkpoint: None,
created_at: "2026-05-11T00:00:00+00:00".to_string(),
},
&sk,
)
.unwrap();
let err = cp.verify(&r2).unwrap_err();
assert!(err.contains("registry_root"), "got: {err}");
}
#[test]
fn checkpoint_fails_with_tampered_signature() {
let r = make_registry(&["vfr_a"]);
let sk = make_key();
let mut cp = RegistryCheckpoint::build(
&r,
CheckpointDraft {
hub_id: "hub:test".to_string(),
sequence: 1,
previous_checkpoint: None,
created_at: "2026-05-11T00:00:00+00:00".to_string(),
},
&sk,
)
.unwrap();
cp.signature = "0".repeat(128);
let err = cp.verify(&r).unwrap_err();
assert!(
err.contains("mismatch") || err.contains("does not verify"),
"got: {err}"
);
}
}