use alloy_primitives::Address;
use blueprint_core::{error, info, warn};
use blueprint_keystore::backends::eigenlayer::EigenlayerBackend;
use blueprint_runner::config::BlueprintEnvironment;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RegistrationStatus {
Active,
Deregistered,
Pending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RuntimeTarget {
Native,
Hypervisor,
Container,
Tee,
}
impl Default for RuntimeTarget {
fn default() -> Self {
Self::Hypervisor
}
}
impl std::fmt::Display for RuntimeTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Native => write!(f, "native"),
Self::Hypervisor => write!(f, "hypervisor"),
Self::Container => write!(f, "container"),
Self::Tee => write!(f, "tee"),
}
}
}
impl std::str::FromStr for RuntimeTarget {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"native" => Ok(Self::Native),
"hypervisor" | "vm" => Ok(Self::Hypervisor),
"container" | "docker" | "kata" => Ok(Self::Container),
"tee" | "confidential" | "confidential-container" => Ok(Self::Tee),
_ => Err(format!(
"Invalid runtime target: '{s}'. Valid options: 'native', 'hypervisor', 'container', 'tee'"
)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvsRegistrationConfig {
pub service_manager: Address,
pub registry_coordinator: Address,
pub operator_state_retriever: Address,
pub strategy_manager: Address,
pub delegation_manager: Address,
pub avs_directory: Address,
pub rewards_coordinator: Address,
pub permission_controller: Address,
pub allocation_manager: Address,
pub strategy_address: Address,
pub stake_registry: Address,
pub blueprint_path: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub container_image: Option<String>,
#[serde(default)]
pub runtime_target: RuntimeTarget,
#[serde(default = "default_allocation_delay")]
pub allocation_delay: u32,
#[serde(default = "default_deposit_amount")]
pub deposit_amount: u128,
#[serde(default = "default_stake_amount")]
pub stake_amount: u64,
#[serde(default = "default_operator_sets")]
pub operator_sets: Vec<u32>,
}
fn default_allocation_delay() -> u32 {
0
}
fn default_deposit_amount() -> u128 {
5_000_000_000_000_000_000_000
}
fn default_stake_amount() -> u64 {
1_000_000_000_000_000_000
}
fn default_operator_sets() -> Vec<u32> {
vec![0]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvsRegistration {
pub operator_address: Address,
pub registered_at: String,
pub status: RegistrationStatus,
pub config: AvsRegistrationConfig,
}
impl AvsRegistrationConfig {
pub fn validate(&self) -> Result<(), String> {
if !self.blueprint_path.exists() {
return Err(format!(
"Blueprint path does not exist: {}",
self.blueprint_path.display()
));
}
if !self.blueprint_path.is_dir() && !self.blueprint_path.is_file() {
return Err(format!(
"Blueprint path is neither a file nor directory: {}",
self.blueprint_path.display()
));
}
if self.blueprint_path.is_file()
&& self.runtime_target != RuntimeTarget::Container
&& self.runtime_target != RuntimeTarget::Tee
{
return Err(format!(
"Pre-compiled binaries are not yet supported for {:?} runtime. \
Please use one of these options:\n\
1. Provide a Rust project directory (containing Cargo.toml)\n\
2. Use Container runtime (--runtime container) with a container image",
self.runtime_target
));
}
match self.runtime_target {
RuntimeTarget::Native => {
#[cfg(not(debug_assertions))]
{
warn!(
"Native runtime selected - this provides NO ISOLATION and should only be used for testing!"
);
}
}
RuntimeTarget::Hypervisor => {
#[cfg(not(target_os = "linux"))]
{
return Err(
"Hypervisor runtime requires Linux/KVM. Use 'native' for local testing on macOS/Windows."
.to_string(),
);
}
}
RuntimeTarget::Container => {
Self::validate_container_image(self.container_image.as_deref(), "Container")?;
}
RuntimeTarget::Tee => {
Self::validate_container_image(self.container_image.as_deref(), "TEE")?;
}
}
Ok(())
}
fn validate_container_image(image: Option<&str>, runtime_label: &str) -> Result<(), String> {
let Some(image) = image else {
return Err(format!(
"{runtime_label} runtime requires 'container_image' field in config. \
Example: \"ghcr.io/my-org/my-avs:latest\""
));
};
if image.trim().is_empty() {
return Err("Container image cannot be empty".to_string());
}
let parts: Vec<&str> = image.split(':').collect();
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
return Err(format!(
"Container image must be 'name:tag' or 'registry/name:tag' format (got: '{image}'). \
Example: \"ghcr.io/my-org/my-avs:latest\" or \"my-image:latest\""
));
}
if parts[0].contains("://") {
return Err(
"Container image should not include protocol (http:// or https://)".to_string(),
);
}
Ok(())
}
}
impl AvsRegistration {
pub fn new(operator_address: Address, config: AvsRegistrationConfig) -> Self {
Self {
operator_address,
registered_at: Utc::now().to_rfc3339(),
status: RegistrationStatus::Active,
config,
}
}
pub fn avs_id(&self) -> Address {
self.config.service_manager
}
pub fn blueprint_id(&self) -> u64 {
let bytes = self.config.service_manager.as_slice();
u64::from_be_bytes([
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
])
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AvsRegistrations {
#[serde(default)]
pub registrations: HashMap<String, AvsRegistration>,
}
impl AvsRegistrations {
pub fn add(&mut self, registration: AvsRegistration) {
let key = format!("{:#x}", registration.config.service_manager);
self.registrations.insert(key, registration);
}
pub fn remove(&mut self, service_manager: Address) -> Option<AvsRegistration> {
let key = format!("{service_manager:#x}");
self.registrations.remove(&key)
}
pub fn get(&self, service_manager: Address) -> Option<&AvsRegistration> {
let key = format!("{service_manager:#x}");
self.registrations.get(&key)
}
pub fn get_mut(&mut self, service_manager: Address) -> Option<&mut AvsRegistration> {
let key = format!("{service_manager:#x}");
self.registrations.get_mut(&key)
}
pub fn active(&self) -> impl Iterator<Item = &AvsRegistration> {
self.registrations
.values()
.filter(|r| r.status == RegistrationStatus::Active)
}
pub fn mark_deregistered(&mut self, service_manager: Address) -> bool {
if let Some(reg) = self.get_mut(service_manager) {
reg.status = RegistrationStatus::Deregistered;
true
} else {
false
}
}
}
pub struct RegistrationStateManager {
state_file: PathBuf,
registrations: AvsRegistrations,
}
impl RegistrationStateManager {
pub fn load() -> Result<Self, crate::error::Error> {
let state_file = Self::default_state_file()?;
Self::load_from_file(&state_file)
}
pub fn load_or_create() -> Result<Self, crate::error::Error> {
match Self::load() {
Ok(manager) => Ok(manager),
Err(_) => {
let state_file = Self::default_state_file()?;
info!(
"Creating new registration state file at {}",
state_file.display()
);
Ok(Self {
state_file,
registrations: AvsRegistrations::default(),
})
}
}
}
pub fn load_from_file(path: &Path) -> Result<Self, crate::error::Error> {
let registrations = if path.exists() {
let contents = std::fs::read_to_string(path).map_err(|e| {
crate::error::Error::Other(format!("Failed to read registration state: {e}"))
})?;
serde_json::from_str(&contents).map_err(|e| {
crate::error::Error::Other(format!("Failed to parse registration state: {e}"))
})?
} else {
info!(
"No existing registration state found at {}, creating new",
path.display()
);
AvsRegistrations::default()
};
Ok(Self {
state_file: path.to_path_buf(),
registrations,
})
}
fn default_state_file() -> Result<PathBuf, crate::error::Error> {
let home = dirs::home_dir()
.ok_or_else(|| crate::error::Error::Other("Cannot determine home directory".into()))?;
let tangle_dir = home.join(".tangle");
std::fs::create_dir_all(&tangle_dir).map_err(|e| {
crate::error::Error::Other(format!("Failed to create .tangle directory: {e}"))
})?;
Ok(tangle_dir.join("eigenlayer_registrations.json"))
}
pub fn save(&self) -> Result<(), crate::error::Error> {
let contents = serde_json::to_string_pretty(&self.registrations).map_err(|e| {
crate::error::Error::Other(format!("Failed to serialize registrations: {e}"))
})?;
std::fs::write(&self.state_file, contents).map_err(|e| {
crate::error::Error::Other(format!("Failed to write registration state: {e}"))
})?;
info!("Saved registration state to {}", self.state_file.display());
Ok(())
}
pub fn registrations(&self) -> &AvsRegistrations {
&self.registrations
}
pub fn registrations_mut(&mut self) -> &mut AvsRegistrations {
&mut self.registrations
}
pub fn register(&mut self, registration: AvsRegistration) -> Result<(), crate::error::Error> {
info!(
"Registering AVS {} for operator {:#x}",
registration.config.service_manager, registration.operator_address
);
self.registrations.add(registration);
self.save()
}
pub fn deregister(&mut self, service_manager: Address) -> Result<(), crate::error::Error> {
info!("Deregistering AVS {:#x}", service_manager);
if self.registrations.mark_deregistered(service_manager) {
self.save()
} else {
Err(crate::error::Error::Other(format!(
"AVS {service_manager:#x} not found in registrations"
)))
}
}
pub async fn verify_on_chain(
&self,
service_manager: Address,
env: &BlueprintEnvironment,
) -> Result<bool, crate::error::Error> {
let registration = self.registrations.get(service_manager).ok_or_else(|| {
crate::error::Error::Other(format!(
"AVS {service_manager:#x} not found in local registrations"
))
})?;
use blueprint_keystore::backends::Backend;
use blueprint_keystore::crypto::k256::K256Ecdsa;
let ecdsa_public = env
.keystore()
.first_local::<K256Ecdsa>()
.map_err(|e| crate::error::Error::Other(format!("Keystore error: {e}")))?;
let ecdsa_secret = env
.keystore()
.expose_ecdsa_secret(&ecdsa_public)
.map_err(|e| crate::error::Error::Other(format!("Keystore error: {e}")))?
.ok_or_else(|| crate::error::Error::Other("No ECDSA secret found".into()))?;
let operator_address = ecdsa_secret.alloy_address().map_err(|e| {
crate::error::Error::Other(format!("Failed to get operator address: {e}"))
})?;
let avs_registry_reader =
eigensdk::client_avsregistry::reader::AvsRegistryChainReader::new(
registration.config.registry_coordinator,
registration.config.operator_state_retriever,
env.http_rpc_endpoint.to_string(),
)
.await
.map_err(|e| {
crate::error::Error::Other(format!("Failed to create AVS registry reader: {e}"))
})?;
avs_registry_reader
.is_operator_registered(operator_address)
.await
.map_err(|e| {
crate::error::Error::Other(format!("Failed to check registration status: {e}"))
})
}
pub async fn reconcile_with_chain(
&mut self,
env: &BlueprintEnvironment,
) -> Result<usize, crate::error::Error> {
let mut reconciled = 0;
let service_managers: Vec<Address> = self
.registrations
.active()
.map(|r| r.config.service_manager)
.collect();
for service_manager in service_managers {
match self.verify_on_chain(service_manager, env).await {
Ok(is_registered) => {
if !is_registered {
warn!(
"AVS {:#x} is registered locally but not on-chain, marking as deregistered",
service_manager
);
self.registrations.mark_deregistered(service_manager);
reconciled += 1;
}
}
Err(e) => {
error!(
"Failed to verify AVS {:#x} on-chain: {}",
service_manager, e
);
}
}
}
if reconciled > 0 {
self.save()?;
}
Ok(reconciled)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validation_nonexistent_path() {
let config = AvsRegistrationConfig {
service_manager: Address::ZERO,
registry_coordinator: Address::ZERO,
operator_state_retriever: Address::ZERO,
strategy_manager: Address::ZERO,
delegation_manager: Address::ZERO,
avs_directory: Address::ZERO,
rewards_coordinator: Address::ZERO,
permission_controller: Address::ZERO,
allocation_manager: Address::ZERO,
strategy_address: Address::ZERO,
stake_registry: Address::ZERO,
blueprint_path: PathBuf::from("/nonexistent/path/to/blueprint"),
runtime_target: RuntimeTarget::Native,
allocation_delay: 0,
deposit_amount: 1000,
stake_amount: 100,
operator_sets: vec![0],
container_image: None,
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_validation_valid_config() {
let temp_dir = tempfile::tempdir().unwrap();
let blueprint_path = temp_dir.path().join("test_blueprint");
std::fs::create_dir_all(&blueprint_path).unwrap();
let config = AvsRegistrationConfig {
service_manager: Address::ZERO,
registry_coordinator: Address::ZERO,
operator_state_retriever: Address::ZERO,
strategy_manager: Address::ZERO,
delegation_manager: Address::ZERO,
avs_directory: Address::ZERO,
rewards_coordinator: Address::ZERO,
permission_controller: Address::ZERO,
allocation_manager: Address::ZERO,
strategy_address: Address::ZERO,
stake_registry: Address::ZERO,
blueprint_path,
runtime_target: RuntimeTarget::Native,
allocation_delay: 0,
deposit_amount: 1000,
stake_amount: 100,
operator_sets: vec![0],
container_image: None,
};
let result = config.validate();
assert!(result.is_ok());
}
#[test]
fn test_runtime_target_parses_tee() {
let parsed: RuntimeTarget = "tee".parse().expect("tee runtime should parse");
assert_eq!(parsed, RuntimeTarget::Tee);
assert_eq!(parsed.to_string(), "tee");
}
#[test]
fn test_validation_tee_requires_container_image() {
let temp_dir = tempfile::tempdir().unwrap();
let blueprint_path = temp_dir.path().join("test_blueprint");
std::fs::create_dir_all(&blueprint_path).unwrap();
let config = AvsRegistrationConfig {
service_manager: Address::ZERO,
registry_coordinator: Address::ZERO,
operator_state_retriever: Address::ZERO,
strategy_manager: Address::ZERO,
delegation_manager: Address::ZERO,
avs_directory: Address::ZERO,
rewards_coordinator: Address::ZERO,
permission_controller: Address::ZERO,
allocation_manager: Address::ZERO,
strategy_address: Address::ZERO,
stake_registry: Address::ZERO,
blueprint_path,
runtime_target: RuntimeTarget::Tee,
allocation_delay: 0,
deposit_amount: 1000,
stake_amount: 100,
operator_sets: vec![0],
container_image: None,
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().contains("container_image"));
}
#[test]
fn test_registration_serialization() {
let config = AvsRegistrationConfig {
service_manager: Address::from([1u8; 20]),
registry_coordinator: Address::from([2u8; 20]),
operator_state_retriever: Address::from([3u8; 20]),
strategy_manager: Address::from([4u8; 20]),
delegation_manager: Address::from([5u8; 20]),
avs_directory: Address::from([6u8; 20]),
rewards_coordinator: Address::from([7u8; 20]),
permission_controller: Address::from([8u8; 20]),
allocation_manager: Address::from([9u8; 20]),
strategy_address: Address::from([10u8; 20]),
stake_registry: Address::from([11u8; 20]),
blueprint_path: PathBuf::from("/path/to/blueprint"),
runtime_target: RuntimeTarget::Native,
allocation_delay: 0,
deposit_amount: 5000,
stake_amount: 1000,
operator_sets: vec![0],
container_image: None,
};
let registration = AvsRegistration::new(Address::from([12u8; 20]), config);
let serialized = serde_json::to_string(®istration).unwrap();
let deserialized: AvsRegistration = serde_json::from_str(&serialized).unwrap();
assert_eq!(registration.operator_address, deserialized.operator_address);
assert_eq!(registration.status, deserialized.status);
}
#[test]
fn test_registrations_management() {
let mut registrations = AvsRegistrations::default();
let config = AvsRegistrationConfig {
service_manager: Address::from([1u8; 20]),
registry_coordinator: Address::from([2u8; 20]),
operator_state_retriever: Address::from([3u8; 20]),
strategy_manager: Address::from([4u8; 20]),
delegation_manager: Address::from([5u8; 20]),
avs_directory: Address::from([6u8; 20]),
rewards_coordinator: Address::from([7u8; 20]),
permission_controller: Address::from([8u8; 20]),
allocation_manager: Address::from([9u8; 20]),
strategy_address: Address::from([10u8; 20]),
stake_registry: Address::from([11u8; 20]),
blueprint_path: PathBuf::from("/path/to/blueprint"),
runtime_target: RuntimeTarget::Native,
allocation_delay: 0,
deposit_amount: 5000,
stake_amount: 1000,
operator_sets: vec![0],
container_image: None,
};
let registration = AvsRegistration::new(Address::from([12u8; 20]), config);
let service_manager = registration.config.service_manager;
registrations.add(registration);
assert!(registrations.get(service_manager).is_some());
assert!(registrations.mark_deregistered(service_manager));
assert_eq!(
registrations.get(service_manager).unwrap().status,
RegistrationStatus::Deregistered
);
assert_eq!(registrations.active().count(), 0);
}
}