diff --git a/.gitignore b/.gitignore index 8f9995e..3d4c2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ coverage/ .claude/ -blacklight_node/ \ No newline at end of file +blacklight_node/ +CLAUDE.md diff --git a/Cargo.toml b/Cargo.toml index 6593041..2301d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,10 @@ members = [ "blacklight-node", "crates/blacklight-contract-clients", "crates/chain-args", + "crates/contract-clients-common", + "crates/erc-8004-contract-clients", "crates/state-file", "keeper", "monitor", - "nilcc-simulator" + "simulator" ] diff --git a/blacklight-node/Cargo.toml b/blacklight-node/Cargo.toml index 8ebc9b0..c2303cd 100644 --- a/blacklight-node/Cargo.toml +++ b/blacklight-node/Cargo.toml @@ -17,6 +17,7 @@ serde_json = "1.0" sha2 = "0.10" term-table = "1.4" tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "signal"] } +tokio-util = "0.7" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/blacklight-node/src/main.rs b/blacklight-node/src/main.rs index 831f198..0565078 100644 --- a/blacklight-node/src/main.rs +++ b/blacklight-node/src/main.rs @@ -1,453 +1,29 @@ -use alloy::primitives::Address; -use alloy::primitives::utils::{format_ether, format_units}; use anyhow::Result; -use args::{CliArgs, NodeConfig, validate_node_requirements}; -use blacklight_contract_clients::{ - BlacklightClient, ContractConfig, - heartbeat_manager::{RoundStartedEvent, Verdict}, - htx::Htx, -}; +use args::{CliArgs, NodeConfig}; use clap::Parser; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; -use tokio::sync::Notify; -use tracing::{debug, error, info, warn}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info}; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; -use verification::HtxVerifier; -use crate::args::MIN_ETH_BALANCE; - -/// Initial reconnection delay in seconds -const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1); -/// Maximum reconnection delay in seconds -const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(60); +use crate::verification::HtxVerifier; mod args; +mod shutdown; +mod supervisor; mod verification; -mod version; mod wallet; -use version::{VERSION, validate_node_version}; - -// ============================================================================ -// Signal Handling -// ============================================================================ - -/// Setup shutdown signal handler (Ctrl+C / SIGTERM) -async fn setup_shutdown_handler(shutdown_notify: Arc) { - #[cfg(unix)] - { - use tokio::signal::unix::{SignalKind, signal}; - - let mut sigterm = - signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler"); - let mut sigint = - signal(SignalKind::interrupt()).expect("Failed to register SIGINT handler"); - - tokio::select! { - _ = sigterm.recv() => { - info!("Shutdown signal received (SIGTERM)"); - } - _ = sigint.recv() => { - info!("Shutdown signal received (SIGINT/Ctrl+C)"); - } - } - - shutdown_notify.notify_waiters(); - } - - #[cfg(not(unix))] - { - match tokio::signal::ctrl_c().await { - Ok(()) => { - info!("Shutdown signal received (Ctrl+C)"); - shutdown_notify.notify_waiters(); - } - Err(err) => { - error!(error = %err, "Failed to listen for shutdown signal"); - } - } - } -} - -// ============================================================================ -// Status Reporting -// ============================================================================ - -/// Print status information (ETH balance, staked balance, verified HTXs) -async fn print_status(client: &BlacklightClient, verified_count: u64) -> Result<()> { - let eth_balance = client.get_balance().await?; - let node_address = client.signer_address(); - let staked_balance = client.staking.stake_of(node_address).await?; - - info!( - "📊 STATUS | ETH: {} | STAKED: {} NIL | Verified HTXs: {}", - format_ether(eth_balance), - format_units(staked_balance, 6)?, - verified_count - ); - - Ok(()) -} - -// ============================================================================ -// HTX Processing -// ============================================================================ - -/// Process a single HTX assignment - verifies and submits result -async fn process_htx_assignment( - client: Arc, - event: RoundStartedEvent, - verifier: &HtxVerifier, - verified_counter: Arc, - shutdown_notify: Arc, - node_address: Address, -) -> Result<()> { - let htx_id = event.heartbeatKey; - // Parse the HTX data - UnifiedHtx automatically detects provider field - let verification_result = match serde_json::from_slice::(&event.rawHTX) { - Ok(htx) => match htx { - Htx::Nillion(htx) => { - info!(htx_id = ?htx_id, "Detected nilCC HTX"); - verifier.verify_nillion_htx(&htx).await - } - Htx::Phala(htx) => { - info!(htx_id = ?htx_id, "Detected Phala HTX"); - verifier.verify_phala_htx(&htx).await - } - }, - Err(e) => { - error!(htx_id = ?htx_id, error = %e, "Failed to parse HTX data"); - // If we parse invalid data, it could be a malicious node, so Failure and it doesn't get rewarded - client - .manager - .respond_htx(event, Verdict::Failure, node_address) - .await?; - info!(htx_id = ?htx_id, "✅ HTX verification submitted"); - return Ok(()); - } - }; - let verdict = match verification_result { - Ok(_) => Verdict::Success, - Err(ref e) => e.verdict(), - }; - - // Submit the verification result - match client - .manager - .respond_htx(event, verdict, node_address) - .await - { - Ok(tx_hash) => { - let count = verified_counter.fetch_add(1, Ordering::SeqCst) + 1; - - match (verdict, verification_result) { - (Verdict::Success, Ok(_)) => { - info!(tx_hash=?tx_hash, "✅ VALID HTX verification submitted"); - } - (Verdict::Failure, Err(e)) => { - info!(tx_hash=?tx_hash, error=?e, verdict="failure", "❌ INVALID HTX verification submitted"); - } - (Verdict::Inconclusive, Err(e)) => { - info!(tx_hash=?tx_hash, error=?e, verdict="inconclusive", "⚠️ INCONCLUSIVE HTX verification submitted"); - } - (_, _) => { - error!(tx_hash=?tx_hash, verdict=?verdict, "Unexpected verification state"); - } - } - - if let Err(e) = print_status(&client, count).await { - warn!(error = %e, "Failed to fetch status information"); - } - - // Validate node version against protocol requirement and initiate shutdown if incompatible - if let Err(e) = validate_node_version(&client).await { - error!(error = %e, "Node version validation failed. Initiating shutdown..."); - shutdown_notify.notify_waiters(); - return Err(anyhow::anyhow!( - "Node version is incompatible with protocol requirement" - )); - } - - // Check if balance is below minimum threshold - match client.get_balance().await { - Ok(balance) => { - if balance < MIN_ETH_BALANCE { - error!( - balance = %format_ether(balance), - min_required = %format_ether(MIN_ETH_BALANCE), - "⚠️ ETH balance below minimum threshold. Initiating shutdown..." - ); - shutdown_notify.notify_waiters(); - return Err(anyhow::anyhow!("Insufficient ETH balance")); - } - } - Err(e) => { - warn!(error = %e, "Failed to check balance after transaction"); - } - } - - Ok(()) - } - Err(e) => { - error!(htx_id = ?htx_id, error = %e, "Failed to respond to HTX"); - Err(e) - } - } -} - -/// Process backlog of historical assignments -async fn process_assignment_backlog( - client: Arc, - node_address: Address, - verifier: &HtxVerifier, - verified_counter: Arc, - shutdown_notify: Arc, -) -> Result<()> { - info!("Checking for pending assignments from before connection"); - - let assigned_events = client.manager.get_htx_assigned_events().await?; - let pending: Vec<_> = assigned_events - .into_iter() - .filter(|e| e.members.contains(&node_address)) - .collect(); - - if pending.is_empty() { - info!("No pending assignments found"); - return Ok(()); - } - - info!( - count = pending.len(), - "Found historical assignments, processing backlog" - ); - - for event in pending { - let htx_id = event.heartbeatKey; - - // Check if already responded - match client.manager.get_node_vote(htx_id, node_address).await { - Ok(Some(_)) => { - debug!(htx_id = ?htx_id, "Already responded HTX, skipping"); - } - Ok(None) => { - info!(htx_id = ?htx_id, "📥 HTX received (backlog)"); - let client_clone = client.clone(); - let verifier = verifier.clone(); - let counter = verified_counter.clone(); - let shutdown_clone = shutdown_notify.clone(); - tokio::spawn(async move { - if let Err(e) = process_htx_assignment( - client_clone, - event, - &verifier, - counter, - shutdown_clone, - node_address, - ) - .await - { - error!(htx_id = ?htx_id, error = %e, "Failed to process pending HTX"); - } - }); - } - Err(e) => { - error!(htx_id = ?htx_id, error = %e, "Failed to check assignment status"); - } - } - } - - info!("Backlog processing complete"); - Ok(()) -} - -// ============================================================================ -// Node Registration -// ============================================================================ - -/// Register node with the contract if not already registered -async fn register_node_if_needed(client: &BlacklightClient, node_address: Address) -> Result<()> { - info!(node_address = %node_address, "Checking node registration"); - - let is_registered = client.staking.is_active_operator(node_address).await?; - - if is_registered { - info!("Node already registered"); - return Ok(()); - } - - info!("Registering node with contract"); - let tx_hash = client.staking.register_operator("".to_string()).await?; - info!(tx_hash = ?tx_hash, "Node registered successfully"); - - Ok(()) -} - -// ============================================================================ -// Client Creation -// ============================================================================ - -/// Create a WebSocket client with exponential backoff retry logic -async fn create_client_with_retry( - config: &NodeConfig, - shutdown_notify: &Arc, -) -> Result { - let mut reconnect_delay = INITIAL_RECONNECT_DELAY; - let max_reconnect_delay = MAX_RECONNECT_DELAY; - - let contract_config = ContractConfig::new( - config.rpc_url.clone(), - config.manager_contract_address, - config.staking_contract_address, - config.token_contract_address, - ); - - loop { - let client_result = - BlacklightClient::new(contract_config.clone(), config.private_key.clone()).await; - - match client_result { - Ok(client) => { - let balance = client.get_balance().await?; - info!(balance = ?balance, "WebSocket connection established"); - return Ok(client); - } - Err(e) => { - error!(error = %e, reconnect_delay = ?reconnect_delay, "Failed to connect WebSocket. Retrying..."); - - // Sleep with ability to be interrupted by shutdown - tokio::select! { - _ = tokio::time::sleep(reconnect_delay) => { - reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay); - } - _ = shutdown_notify.notified() => { - return Err(anyhow::anyhow!("Shutdown signal received during connection retry")); - } - } - } - } - } -} - -// ============================================================================ -// Event Listening -// ============================================================================ - -/// Listen for HTX assignment events and process them -async fn run_event_listener( - client: Arc, - node_address: Address, - shutdown_notify: Arc, - verifier: &HtxVerifier, - verified_counter: Arc, -) -> Result<()> { - let client_for_callback = client.clone(); - let counter_for_callback = verified_counter.clone(); - let shutdown_for_callback = shutdown_notify.clone(); - - let manager = Arc::new(client.manager.clone()); - let listen_future = manager.listen_htx_assigned_for_node(node_address, move |event| { - let client = client_for_callback.clone(); - let counter = counter_for_callback.clone(); - let shutdown_clone = shutdown_for_callback.clone(); - - async move { - let htx_id = event.heartbeatKey; - let node_addr = client.signer_address(); - let verifier = verifier.clone(); - tokio::spawn(async move { - // Check if already responded - match client.manager.get_node_vote(htx_id, node_addr).await { - Ok(Some(_)) => (), - Ok(None) => { - info!(htx_id = ?htx_id, "📥 HTX received"); - if let Err(e) = process_htx_assignment( - client, - event, - &verifier, - counter, - shutdown_clone, - node_address, - ) - .await - { - error!(htx_id = ?htx_id, error = %e, "Failed to process real-time HTX"); - } - } - Err(e) => { - error!(htx_id = ?htx_id, error = %e, "Failed to get assignment for HTX"); - } - } - }); - - Ok(()) - } - }); - - // Listen for either events or shutdown signal - tokio::select! { - result = listen_future => { - result?; - Ok(()) - }, - _ = shutdown_notify.notified() => { - info!("Shutdown signal received during event listening"); - Err(anyhow::anyhow!("Shutdown requested")) - } - } -} - -// ============================================================================ -// Shutdown -// ============================================================================ - -/// Deactivate node from contract on shutdown -async fn deactivate_node_on_shutdown( - config: &NodeConfig, - node_address: Option
, -) -> Result<()> { - info!("Initiating graceful shutdown"); - - let Some(addr) = node_address else { - warn!("Node was never registered, skipping deactivation"); - return Ok(()); - }; - - info!(node_address = %addr, "Deactivating node from contract"); - - let contract_config = ContractConfig::new( - config.rpc_url.clone(), - config.manager_contract_address, - config.staking_contract_address, - config.token_contract_address, - ); - - let client = BlacklightClient::new(contract_config, config.private_key.clone()).await?; - let tx_hash = client.staking.deactivate_operator().await?; - info!(tx_hash = ?tx_hash, "Node deactivated successfully"); - - Ok(()) -} - -// ============================================================================ -// Main -// ============================================================================ - #[tokio::main] async fn main() -> Result<()> { - // Initialize tracing with filters to reduce noise from dependencies + // Initialize tracing let filter = EnvFilter::builder() .with_default_directive(tracing::Level::ERROR.into()) .with_default_directive("attestation_verification=warn".parse()?) .with_default_directive("alloy_transport_ws=off".parse()?) .from_env_lossy() - // Silence noisy attestation verification modules .add_directive("nilcc_artifacts=warn".parse()?) - // Silence Alloy framework noise .add_directive("alloy=warn".parse()?) .add_directive("alloy_pubsub=error".parse()?) - // Keep blacklight logs at info level .add_directive("blacklight=info".parse()?) .add_directive("blacklight_node=info".parse()?); @@ -461,114 +37,19 @@ async fn main() -> Result<()> { let verifier = HtxVerifier::new(cli_args.artifact_cache.clone(), cli_args.cert_cache.clone())?; let config = NodeConfig::load(cli_args).await?; - // Create initial client to validate requirements - let contract_config = ContractConfig::new( - config.rpc_url.clone(), - config.manager_contract_address, - config.staking_contract_address, - config.token_contract_address, - ); - let validation_client = - BlacklightClient::new(contract_config, config.private_key.clone()).await?; - - // Validate node has sufficient ETH and staked NIL tokens - validate_node_requirements( - &validation_client, - &config.rpc_url, - config.was_wallet_created, - ) - .await?; - - // Validate node version against protocol requirement - validate_node_version(&validation_client).await?; - - info!(version = VERSION, "Node initialized"); - info!("Press Ctrl+C to gracefully shutdown and deactivate"); - - // Setup graceful shutdown handler - let shutdown_notify = Arc::new(Notify::new()); - let shutdown_notify_clone = shutdown_notify.clone(); + // Setup shutdown handler + let shutdown_token = CancellationToken::new(); + let shutdown_token_clone = shutdown_token.clone(); tokio::spawn(async move { - setup_shutdown_handler(shutdown_notify_clone).await; + shutdown::shutdown_signal(shutdown_token_clone).await; }); - // Counter for verified HTXs (for status reporting) - let verified_counter = Arc::new(AtomicU64::new(0)); - - // Main reconnection loop - let mut node_address: Option
= None; - let mut reconnect_delay = INITIAL_RECONNECT_DELAY; - let max_reconnect_delay = MAX_RECONNECT_DELAY; - - loop { - info!("Starting WebSocket event listener with auto-reconnection"); - - // Create client with retry logic - let client = match create_client_with_retry(&config, &shutdown_notify).await { - Ok(client) => client, - Err(_) => break, // Shutdown requested or unrecoverable error - }; - - let current_address = client.signer_address(); - node_address = Some(current_address); - - // Register node if needed - if let Err(e) = register_node_if_needed(&client, current_address).await { - error!(error = %e, reconnect_delay = ?reconnect_delay, "Failed to register node. Retrying..."); - - // Exit the loop - std::process::exit(1); - } - - let client_arc = Arc::new(client); - - // Process any backlog of assignments - if let Err(e) = process_assignment_backlog( - client_arc.clone(), - current_address, - &verifier, - verified_counter.clone(), - shutdown_notify.clone(), - ) - .await - { - error!(error = %e, "Failed to query historical assignments"); - } - - // Start listening for events - match run_event_listener( - client_arc, - current_address, - shutdown_notify.clone(), - &verifier, - verified_counter.clone(), - ) - .await - { - Ok(_) => { - warn!(reconnect_delay = ?reconnect_delay, "WebSocket listener exited normally. Reconnecting..."); - } - Err(e) if e.to_string().contains("Shutdown") => { - break; // Graceful shutdown - } - Err(e) => { - error!(error = %e, reconnect_delay = ?reconnect_delay, "WebSocket listener error. Reconnecting..."); - } - } - - // Sleep before reconnecting, with ability to be interrupted by shutdown - tokio::select! { - _ = tokio::time::sleep(reconnect_delay) => { - reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay); - } - _ = shutdown_notify.notified() => { - break; // Shutdown requested - } - } - } + // Create and run supervisor (handles connection, validation, and event processing) + let supervisor = supervisor::Supervisor::new(&config, &verifier, shutdown_token).await?; + let client = supervisor.run().await?; - // Graceful shutdown - deactivate node from contract - if let Err(e) = deactivate_node_on_shutdown(&config, node_address).await { + // Graceful shutdown - deactivate node + if let Err(e) = shutdown::deactivate_node(&client).await { error!(error = %e, "Failed to deactivate node gracefully"); } diff --git a/blacklight-node/src/shutdown.rs b/blacklight-node/src/shutdown.rs new file mode 100644 index 0000000..6a211f7 --- /dev/null +++ b/blacklight-node/src/shutdown.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use blacklight_contract_clients::BlacklightClient; +use tokio_util::sync::CancellationToken; +use tracing::info; + +/// Setup shutdown signal handler (Ctrl+C / SIGTERM) +pub async fn shutdown_signal(shutdown_token: CancellationToken) { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut sigterm = + signal(SignalKind::terminate()).expect("Failed to register SIGTERM handler"); + let mut sigint = + signal(SignalKind::interrupt()).expect("Failed to register SIGINT handler"); + + tokio::select! { + _ = sigterm.recv() => { + info!("Shutdown signal received (SIGTERM)"); + } + _ = sigint.recv() => { + info!("Shutdown signal received (SIGINT/Ctrl+C)"); + } + } + + shutdown_token.cancel(); + } + + #[cfg(not(unix))] + { + use tracing::error; + + match tokio::signal::ctrl_c().await { + Ok(()) => { + info!("Shutdown signal received (Ctrl+C)"); + shutdown_token.cancel(); + } + Err(err) => { + error!(error = %err, "Failed to listen for shutdown signal"); + } + } + } +} + +/// Deactivate node from contract on shutdown +pub async fn deactivate_node(client: &BlacklightClient) -> Result<()> { + let node_address = client.signer_address(); + info!("Initiating graceful shutdown"); + info!(node_address = %node_address, "Deactivating node from contract"); + + let tx_hash = client.staking.deactivate_operator().await?; + info!(node_address = %node_address, tx_hash = ?tx_hash, "Node deactivated successfully"); + + Ok(()) +} diff --git a/blacklight-node/src/supervisor/events.rs b/blacklight-node/src/supervisor/events.rs new file mode 100644 index 0000000..50b5658 --- /dev/null +++ b/blacklight-node/src/supervisor/events.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use blacklight_contract_clients::BlacklightClient; +use std::sync::Arc; +use tracing::info; + +use super::htx::{HtxEventSource, HtxProcessor}; +/// Listen for HTX assignment events and process them +pub async fn run_event_listener(client: BlacklightClient, processor: HtxProcessor) -> Result<()> { + let client_for_callback = client.clone(); + let processor_for_callback = processor.clone(); + + let manager = Arc::new(client.manager.clone()); + let node_address = processor.node_address(); + let listen_future = manager.listen_htx_assigned_for_node(node_address, move |event| { + let client = client_for_callback.clone(); + let processor = processor_for_callback.clone(); + async move { + let vote_address = client.signer_address(); + processor.spawn_processing(event, vote_address, HtxEventSource::Realtime, true); + + Ok(()) + } + }); + + // Listen for either events or shutdown signal + let shutdown_token = processor.shutdown_token(); + tokio::select! { + result = listen_future => { + result?; + Ok(()) + }, + _ = shutdown_token.cancelled() => { + info!("Shutdown signal received during event listening"); + Err(anyhow::anyhow!("Shutdown requested")) + } + } +} diff --git a/blacklight-node/src/supervisor/htx.rs b/blacklight-node/src/supervisor/htx.rs new file mode 100644 index 0000000..402d20a --- /dev/null +++ b/blacklight-node/src/supervisor/htx.rs @@ -0,0 +1,251 @@ +use alloy::primitives::Address; +use anyhow::Result; +use blacklight_contract_clients::{ + BlacklightClient, + heartbeat_manager::{RoundStartedEvent, Verdict}, + htx::Htx, +}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; + +use crate::supervisor::status::{check_minimum_balance, print_status}; +use crate::supervisor::version::validate_node_version; +use crate::verification::HtxVerifier; + +#[derive(Clone)] +pub struct HtxProcessor { + client: BlacklightClient, + verifier: HtxVerifier, + verified_counter: Arc, + node_address: Address, + shutdown_token: CancellationToken, +} + +impl HtxProcessor { + pub fn new( + client: BlacklightClient, + verifier: HtxVerifier, + verified_counter: Arc, + node_address: Address, + shutdown_token: CancellationToken, + ) -> Self { + Self { + client, + verifier, + verified_counter, + node_address, + shutdown_token, + } + } + + pub fn node_address(&self) -> Address { + self.node_address + } + + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown_token.clone() + } + + pub fn spawn_processing( + &self, + event: RoundStartedEvent, + vote_address: Address, + source: HtxEventSource, + run_post_process: bool, + ) { + let processor = self.clone(); + tokio::spawn(async move { + let htx_id = event.heartbeatKey; + + let client = processor.client.clone(); + // Check if already responded + match client.manager.get_node_vote(htx_id, vote_address).await { + Ok(Some(_)) => { + if let Some(message) = source.already_responded_message() { + debug!(htx_id = ?htx_id, "{}", message); + } + } + Ok(None) => { + info!(htx_id = ?htx_id, "{}", source.received_message()); + match processor.process_htx_assignment(client, event).await { + Ok(Some(count)) => { + if run_post_process + && let Err(e) = processor.post_process_checks(count).await + { + warn!(error = %e, "Failed to post-process events"); + } + } + Ok(None) => {} + Err(e) => { + error!(htx_id = ?htx_id, error = %e, "{}", source.process_error_message()); + } + } + } + Err(e) => { + error!(htx_id = ?htx_id, error = %e, "{}", source.vote_error_message()); + } + } + }); + } + + /// Process a single HTX assignment - verifies and submits result + pub async fn process_htx_assignment( + &self, + client: BlacklightClient, + event: RoundStartedEvent, + ) -> Result> { + let htx_id = event.heartbeatKey; + // Parse the HTX data - tries JSON first (nilCC/Phala), then ABI decoding (ERC-8004) + let verification_result = match Htx::try_parse(&event.rawHTX) { + Ok(htx) => match htx { + Htx::Nillion(htx) => { + info!(htx_id = ?htx_id, "Detected nilCC HTX"); + self.verifier.verify_nillion_htx(&htx).await + } + Htx::Phala(htx) => { + info!(htx_id = ?htx_id, "Detected Phala HTX"); + self.verifier.verify_phala_htx(&htx).await + } + Htx::Erc8004(htx) => { + info!( + htx_id = ?htx_id, + agent_id = %htx.agent_id, + request_uri = %htx.request_uri, + "Detected ERC-8004 validation HTX" + ); + self.verifier.verify_erc8004_htx(&htx).await + } + }, + Err(e) => { + error!(htx_id = ?htx_id, error = %e, "Failed to parse HTX data"); + // If we parse invalid data, it could be a malicious node, so Failure and it doesn't get rewarded + client + .manager + .respond_htx(event, Verdict::Failure, self.node_address) + .await?; + info!(htx_id = ?htx_id, "✅ HTX verification submitted"); + return Ok(None); + } + }; + let verdict = match verification_result { + Ok(_) => Verdict::Success, + Err(ref e) => e.verdict(), + }; + + // Submit the verification result + match client + .manager + .respond_htx(event, verdict, self.node_address) + .await + { + Ok(tx_hash) => { + let count = self.verified_counter.fetch_add(1, Ordering::SeqCst) + 1; + + match (verdict, verification_result) { + (Verdict::Success, Ok(_)) => { + info!(tx_hash=?tx_hash, "✅ VALID HTX verification submitted"); + } + (Verdict::Failure, Err(e)) => { + info!(tx_hash=?tx_hash, error=?e, verdict="failure", "❌ INVALID HTX verification submitted"); + } + (Verdict::Inconclusive, Err(e)) => { + info!(tx_hash=?tx_hash, error=?e, verdict="inconclusive", "⚠️ INCONCLUSIVE HTX verification submitted"); + } + (_, _) => { + error!(tx_hash=?tx_hash, verdict=?verdict, "Unexpected verification state"); + } + } + + Ok(Some(count)) + } + Err(e) => { + error!(htx_id = ?htx_id, error = %e, "Failed to respond to HTX"); + Err(e) + } + } + } + + /// Process backlog of historical assignments + pub async fn process_assignment_backlog(&self, client: BlacklightClient) -> Result<()> { + info!("Checking for pending assignments from before connection"); + + let assigned_events = client.manager.get_htx_assigned_events().await?; + let pending: Vec<_> = assigned_events + .into_iter() + .filter(|e| e.members.contains(&self.node_address)) + .collect(); + + if pending.is_empty() { + info!("No pending assignments found"); + return Ok(()); + } + + info!( + count = pending.len(), + "Found historical assignments, processing backlog" + ); + + for event in pending { + self.spawn_processing(event, self.node_address, HtxEventSource::Backlog, false); + } + + info!("Backlog processing complete"); + Ok(()) + } + + pub async fn post_process_checks(&self, verified_count: u64) -> Result<()> { + let client = self.client.clone(); + let shutdown_token = self.shutdown_token.clone(); + if let Err(e) = print_status(&client, verified_count).await { + warn!(error = %e, "Failed to fetch status information"); + } + + if let Err(e) = check_minimum_balance(&client, &shutdown_token).await { + warn!(error = %e, "Failed to check minimum balance is above minimum threshold"); + } + + if let Err(e) = validate_node_version(&client).await { + warn!(error = %e, "Failed to validate node version against protocol requirement"); + } + + Ok(()) + } +} + +#[derive(Copy, Clone)] +pub enum HtxEventSource { + Realtime, + Backlog, +} + +impl HtxEventSource { + fn received_message(self) -> &'static str { + match self { + Self::Realtime => "📥 HTX received", + Self::Backlog => "📥 HTX received (backlog)", + } + } + + fn already_responded_message(self) -> Option<&'static str> { + match self { + Self::Realtime => None, + Self::Backlog => Some("Already responded HTX, skipping"), + } + } + + fn process_error_message(self) -> &'static str { + match self { + Self::Realtime => "Failed to process real-time HTX", + Self::Backlog => "Failed to process pending HTX", + } + } + + fn vote_error_message(self) -> &'static str { + match self { + Self::Realtime => "Failed to get assignment for HTX", + Self::Backlog => "Failed to check assignment status", + } + } +} diff --git a/blacklight-node/src/supervisor/mod.rs b/blacklight-node/src/supervisor/mod.rs new file mode 100644 index 0000000..a8167e6 --- /dev/null +++ b/blacklight-node/src/supervisor/mod.rs @@ -0,0 +1,222 @@ +use alloy::primitives::Address; +use anyhow::{Result, bail}; +use blacklight_contract_clients::{BlacklightClient, ContractConfig}; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::time::Duration; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::args::{NodeConfig, validate_node_requirements}; +use crate::verification::HtxVerifier; + +use crate::supervisor::htx::HtxProcessor; +use crate::supervisor::version::validate_node_version; + +mod events; +mod htx; +mod status; +mod version; + +/// Initial reconnection delay +const INITIAL_RECONNECT_DELAY: Duration = Duration::from_secs(1); +/// Maximum reconnection delay +const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(60); + +/// Node supervisor - manages WebSocket connection, reconnection, and event processing +pub struct Supervisor<'a> { + config: &'a NodeConfig, + verifier: &'a HtxVerifier, + shutdown_token: CancellationToken, + verified_counter: Arc, + node_address: Address, + reconnect_delay: Duration, + client: BlacklightClient, +} + +impl<'a> Supervisor<'a> { + /// Create a new supervisor, establishing the initial connection and validating requirements + pub async fn new( + config: &'a NodeConfig, + verifier: &'a HtxVerifier, + shutdown_token: CancellationToken, + ) -> Result { + let client = Self::create_client_with_retry(config, &shutdown_token).await?; + let node_address = client.signer_address(); + + // Validate node version against protocol requirement + validate_node_version(&client).await?; + + // Validate node has sufficient ETH and staked NIL tokens + validate_node_requirements(&client, &config.rpc_url, config.was_wallet_created).await?; + + info!(node_address = %node_address, "Node initialized"); + + Ok(Self { + config, + verifier, + shutdown_token, + verified_counter: Arc::new(AtomicU64::new(0)), + node_address, + reconnect_delay: INITIAL_RECONNECT_DELAY, + client, + }) + } + + /// Run the supervisor loop, returns the client for use in shutdown + pub async fn run(mut self) -> Result { + loop { + info!("Starting WebSocket event listener with auto-reconnection"); + info!("Press Ctrl+C to gracefully shutdown and deactivate"); + + // Use existing client or create a new one + let client = self.client.clone(); + + // Register node if needed + if let Err(e) = self.register_node_if_needed(&client).await { + error!(error = %e, "Failed to register node"); + std::process::exit(1); + } + + // Process any backlog of assignments + if let Err(e) = self.process_backlog(client.clone()).await { + error!(error = %e, "Failed to query historical assignments"); + } + + // Start listening for events + match self.listen_for_events(client).await { + Ok(_) => { + warn!("WebSocket listener exited normally. Reconnecting..."); + if self.reconnect_client().await? { + break; + } + } + Err(e) if e.to_string().contains("Shutdown") => { + break; + } + Err(e) => { + error!(error = %e, "WebSocket listener error. Reconnecting..."); + if self.reconnect_client().await? { + break; + } + } + } + } + + Ok(self.client) + } + + /// Create a new WebSocket client + async fn create_client(config: &NodeConfig) -> Result { + let contract_config = ContractConfig::new( + config.rpc_url.clone(), + config.manager_contract_address, + config.staking_contract_address, + config.token_contract_address, + ); + BlacklightClient::new(contract_config, config.private_key.clone()).await + } + + /// Create a client with retry/backoff. Returns Shutdown error if cancelled. + async fn create_client_with_retry( + config: &NodeConfig, + shutdown_token: &CancellationToken, + ) -> Result { + let mut reconnect_delay = INITIAL_RECONNECT_DELAY; + loop { + match Self::create_client(config).await { + Ok(client) => return Ok(client), + Err(e) => { + error!(error = %e, "Failed to create client. Retrying..."); + let sleep = tokio::time::sleep(reconnect_delay); + tokio::select! { + _ = sleep => { + reconnect_delay = std::cmp::min( + reconnect_delay * 2, + MAX_RECONNECT_DELAY + ); + } + _ = shutdown_token.cancelled() => { + bail!("Shutdown requested during initial connect"); + } + } + } + } + } + } + + /// Register node with the contract if not already registered + async fn register_node_if_needed(&self, client: &BlacklightClient) -> Result<()> { + info!(node_address = %self.node_address, "Checking node registration"); + + let is_registered = client.staking.is_active_operator(self.node_address).await?; + + if is_registered { + info!("Node already registered"); + return Ok(()); + } + + info!("Registering node with contract"); + let tx_hash = client.staking.register_operator("".to_string()).await?; + info!(tx_hash = ?tx_hash, "Node registered successfully"); + + Ok(()) + } + + /// Process backlog of historical assignments + async fn process_backlog(&self, client: BlacklightClient) -> Result<()> { + self.build_htx_processor(client.clone()) + .process_assignment_backlog(client) + .await + } + + /// Listen for HTX assignment events + async fn listen_for_events(&self, client: BlacklightClient) -> Result<()> { + events::run_event_listener(client.clone(), self.build_htx_processor(client)).await + } + + fn build_htx_processor(&self, client: BlacklightClient) -> HtxProcessor { + HtxProcessor::new( + client, + self.verifier.clone(), + self.verified_counter.clone(), + self.node_address, + self.shutdown_token.clone(), + ) + } + + /// Reconnect the client with retry/backoff. Returns true if shutdown was requested. + async fn reconnect_client(&mut self) -> Result { + loop { + match Self::create_client(self.config).await { + Ok(client) => { + self.client = client; + self.reconnect_delay = INITIAL_RECONNECT_DELAY; + return Ok(false); + } + Err(e) => { + error!(error = %e, "Failed to create client. Retrying..."); + if self.wait_before_reconnect().await { + return Ok(true); + } + } + } + } + } + + /// Wait before reconnecting, returns true if shutdown was requested + async fn wait_before_reconnect(&mut self) -> bool { + tokio::select! { + _ = tokio::time::sleep(self.reconnect_delay) => { + self.reconnect_delay = std::cmp::min( + self.reconnect_delay * 2, + MAX_RECONNECT_DELAY + ); + false + } + _ = self.shutdown_token.cancelled() => { + true + } + } + } +} diff --git a/blacklight-node/src/supervisor/status.rs b/blacklight-node/src/supervisor/status.rs new file mode 100644 index 0000000..5400e80 --- /dev/null +++ b/blacklight-node/src/supervisor/status.rs @@ -0,0 +1,49 @@ +use alloy::primitives::utils::{format_ether, format_units}; +use anyhow::{Result, bail}; +use blacklight_contract_clients::BlacklightClient; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::args::MIN_ETH_BALANCE; + +/// Print status information (ETH balance, staked balance, verified HTXs) +pub async fn print_status(client: &BlacklightClient, verified_count: u64) -> Result<()> { + let eth_balance = client.get_balance().await?; + let node_address = client.signer_address(); + let staked_balance = client.staking.stake_of(node_address).await?; + + info!( + "📊 STATUS | ETH: {} | STAKED: {} NIL | Verified HTXs since boot: {}", + format_ether(eth_balance), + format_units(staked_balance, 6)?, + verified_count + ); + + Ok(()) +} + +/// Print status and check balance after HTX processing +pub async fn check_minimum_balance( + client: &BlacklightClient, + shutdown_token: &CancellationToken, +) -> Result<()> { + // Check if balance is below minimum threshold + match client.get_balance().await { + Ok(balance) => { + if balance < MIN_ETH_BALANCE { + error!( + balance = %format_ether(balance), + min_required = %format_ether(MIN_ETH_BALANCE), + "⚠️ ETH balance below minimum threshold. Initiating shutdown..." + ); + shutdown_token.cancel(); + bail!("Insufficient ETH balance"); + } + } + Err(e) => { + warn!(error = %e, "Failed to check balance after transaction"); + } + } + + Ok(()) +} diff --git a/blacklight-node/src/version.rs b/blacklight-node/src/supervisor/version.rs similarity index 99% rename from blacklight-node/src/version.rs rename to blacklight-node/src/supervisor/version.rs index f82492c..4862a03 100644 --- a/blacklight-node/src/version.rs +++ b/blacklight-node/src/supervisor/version.rs @@ -1,4 +1,4 @@ -use anyhow::{Result, anyhow}; +use anyhow::{Result, anyhow, bail}; use semver::Version; use blacklight_contract_clients::BlacklightClient; @@ -121,12 +121,12 @@ pub async fn validate_node_version(client: &BlacklightClient) -> Result<()> { "Node version is incompatible with protocol requirement; upgrade required" ); - Err(anyhow!( + bail!( "Node version {} is incompatible with required {}. Upgrade with: {}", VERSION, required_version, upgrade_cmd - )) + ); } } } diff --git a/blacklight-node/src/verification.rs b/blacklight-node/src/verification.rs index 2f8bdd8..aa1293b 100644 --- a/blacklight-node/src/verification.rs +++ b/blacklight-node/src/verification.rs @@ -12,7 +12,7 @@ use attestation_verification::{ }; use attestation_verification::{VerificationError as ExtVerificationError, VmType}; use blacklight_contract_clients::heartbeat_manager::Verdict; -use blacklight_contract_clients::htx::{NillionHtx, PhalaHtx}; +use blacklight_contract_clients::htx::{Erc8004Htx, NillionHtx, PhalaHtx}; use dcap_qvl::collateral::get_collateral_and_verify; use reqwest::Client; use sha2::{Digest, Sha256}; @@ -31,6 +31,7 @@ pub enum VerificationError { PhalaEventLogParse(String), FetchCerts(String), DetectProcessor(String), + Erc8004FetchUri(String), // Malicious errors - cryptographic verification failures VerifyReport(String), @@ -39,6 +40,7 @@ pub enum VerificationError { PhalaComposeHashMismatch, PhalaQuoteVerify(String), InvalidCertificate(String), + Erc8004InvalidUri(String), } impl VerificationError { @@ -58,14 +60,16 @@ impl VerificationError { | PhalaEventLogParse(_) | FetchCerts(_) | InvalidCertificate(_) - | DetectProcessor(_) => Verdict::Inconclusive, + | DetectProcessor(_) + | Erc8004FetchUri(_) => Verdict::Inconclusive, // Failure - cryptographic verification failures (indicates potential tampering) VerifyReport(_) | MeasurementHash(_) | NotInBuilderIndex | PhalaComposeHashMismatch - | PhalaQuoteVerify(_) => Verdict::Failure, + | PhalaQuoteVerify(_) + | Erc8004InvalidUri(_) => Verdict::Failure, } } @@ -92,6 +96,7 @@ impl VerificationError { FetchCerts(e) => format!("could not fetch AMD certificates: {e}"), DetectProcessor(e) => format!("could not detect processor type: {e}"), InvalidCertificate(e) => format!("invalid certificate obtained from AMD: {e}"), + Erc8004FetchUri(e) => format!("could not fetch ERC-8004 request URI: {e}"), // Malicious errors VerifyReport(e) => format!("attestation report verification failed: {e}"), @@ -99,6 +104,7 @@ impl VerificationError { NotInBuilderIndex => "measurement not found in builder index".to_string(), PhalaComposeHashMismatch => "compose-hash mismatch".to_string(), PhalaQuoteVerify(e) => format!("quote verification failed: {e}"), + Erc8004InvalidUri(e) => format!("invalid ERC-8004 request URI: {e}"), } } } @@ -254,6 +260,44 @@ impl HtxVerifier { Ok(bundle.report) } + /// Verify an ERC-8004 validation HTX by checking the request URI is accessible. + /// + /// Steps: + /// 1. Validate the request_uri is a valid URL + /// 2. Fetch the URL and check it returns a successful response + /// + /// Returns Ok(()) if verification succeeds, Err(VerificationError) otherwise. + pub async fn verify_erc8004_htx(&self, htx: &Erc8004Htx) -> Result<(), VerificationError> { + // Validate the URI is a proper URL + let url = reqwest::Url::parse(&htx.request_uri) + .map_err(|e| VerificationError::Erc8004InvalidUri(e.to_string()))?; + + // Only allow http/https schemes + if url.scheme() != "http" && url.scheme() != "https" { + return Err(VerificationError::Erc8004InvalidUri(format!( + "unsupported scheme: {}", + url.scheme() + ))); + } + + // Fetch the URL to verify it's accessible + let client = Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(5)) + .build() + .expect("Failed to build HTTP client"); + + client + .get(url) + .send() + .await + .map_err(|e| VerificationError::Erc8004FetchUri(e.to_string()))? + .error_for_status() + .map_err(|e| VerificationError::Erc8004FetchUri(e.to_string()))?; + + Ok(()) + } + /// Verify a Phala HTX by checking compose hash and quote. /// /// Steps: diff --git a/crates/blacklight-contract-clients/Cargo.toml b/crates/blacklight-contract-clients/Cargo.toml index 659c674..2f09b0b 100644 --- a/crates/blacklight-contract-clients/Cargo.toml +++ b/crates/blacklight-contract-clients/Cargo.toml @@ -7,9 +7,11 @@ edition = "2024" anyhow = "1.0" alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } alloy-provider = { version = "1.1", features = ["ws"] } +contract-clients-common = { path = "../contract-clients-common" } futures-util = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = { version = "3.16", features = ["hex"] } +thiserror = "1.0" tokio = { version = "1.49", features = ["sync"] } tracing = "0.1" diff --git a/crates/blacklight-contract-clients/src/blacklight_client.rs b/crates/blacklight-contract-clients/src/blacklight_client.rs index 5f6c161..95dcfe5 100644 --- a/crates/blacklight-contract-clients/src/blacklight_client.rs +++ b/crates/blacklight-contract-clients/src/blacklight_client.rs @@ -3,20 +3,15 @@ use crate::{ StakingOperatorsClient, }; use alloy::{ - network::{Ethereum, EthereumWallet, NetworkWallet}, - primitives::{Address, B256, TxKind, U256}, - providers::{DynProvider, Provider, ProviderBuilder, WsConnect}, - rpc::types::TransactionRequest, - signers::local::PrivateKeySigner, + primitives::{Address, B256, U256}, + providers::DynProvider, }; -use std::sync::Arc; -use tokio::sync::Mutex; +use contract_clients_common::ProviderContext; /// High-level wrapper bundling all contract clients with a shared Alloy provider. #[derive(Clone)] pub struct BlacklightClient { - provider: DynProvider, - wallet: EthereumWallet, + ctx: ProviderContext, pub manager: HeartbeatManagerClient, pub token: NilTokenClient, pub staking: StakingOperatorsClient, @@ -25,26 +20,26 @@ pub struct BlacklightClient { impl BlacklightClient { pub async fn new(config: ContractConfig, private_key: String) -> anyhow::Result { - let rpc_url = config.rpc_url.clone(); - let ws_url = rpc_url - .replace("http://", "ws://") - .replace("https://", "wss://"); + let ctx = ProviderContext::with_ws_retries( + &config.rpc_url, + &private_key, + Some(config.max_ws_retries), + ) + .await?; - // Build WS transport with configurable retries - let ws = WsConnect::new(ws_url).with_max_retries(config.max_ws_retries); - let signer: PrivateKeySigner = private_key.parse::()?; - let wallet = EthereumWallet::from(signer); - - // Build a provider that can sign transactions, then erase the concrete type - let provider: DynProvider = ProviderBuilder::new() - .wallet(wallet.clone()) - .with_simple_nonce_management() - .with_gas_estimation() - .connect_ws(ws) - .await? - .erased(); + Self::from_context(ctx, config).await + } - let tx_lock = Arc::new(Mutex::new(())); + /// Create a client from an existing [`ProviderContext`]. + /// + /// Use this when you want to share the same provider, wallet, and nonce + /// tracker across multiple clients (e.g. `BlacklightClient` and `Erc8004Client`). + pub async fn from_context( + ctx: ProviderContext, + config: ContractConfig, + ) -> anyhow::Result { + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); // Instantiate contract clients using the shared provider let manager = @@ -54,11 +49,10 @@ impl BlacklightClient { let protocol_config_address = staking.protocol_config().await?; let protocol_config = - ProtocolConfigClient::new(provider.clone(), protocol_config_address, tx_lock.clone()); + ProtocolConfigClient::new(provider.clone(), protocol_config_address, tx_lock); Ok(Self { - provider, - wallet, + ctx, manager, token, staking, @@ -68,31 +62,21 @@ impl BlacklightClient { /// Get the signer address pub fn signer_address(&self) -> Address { - >::default_signer_address(&self.wallet) + self.ctx.signer_address() } /// Get the balance of the wallet pub async fn get_balance(&self) -> anyhow::Result { - let address = self.signer_address(); - Ok(self.provider.get_balance(address).await?) + self.ctx.get_balance().await } /// Get the balance of a specific address pub async fn get_balance_of(&self, address: Address) -> anyhow::Result { - Ok(self.provider.get_balance(address).await?) + self.ctx.get_balance_of(address).await } /// Send ETH to an address pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { - let tx = TransactionRequest { - to: Some(TxKind::Call(to)), - value: Some(amount), - max_priority_fee_per_gas: Some(0), - ..Default::default() - }; - - let tx_hash = self.provider.send_transaction(tx).await?.watch().await?; - - Ok(tx_hash) + self.ctx.send_eth(to, amount).await } } diff --git a/crates/blacklight-contract-clients/src/common/mod.rs b/crates/blacklight-contract-clients/src/common/mod.rs deleted file mode 100644 index 8d581c0..0000000 --- a/crates/blacklight-contract-clients/src/common/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::common::errors::decode_any_error; -use alloy::{ - contract::{CallBuilder, CallDecoder}, - providers::Provider, -}; -use anyhow::anyhow; - -pub mod errors; -pub mod event_helper; -pub mod tx_submitter; - -pub async fn overestimate_gas( - call: &CallBuilder, -) -> anyhow::Result { - // Estimate gas and add a 50% buffer - let estimated_gas = call.estimate_gas().await.map_err(|e| { - let decoded = decode_any_error(&e); - anyhow!("failed to estimate gas: {decoded}") - })?; - let gas_with_buffer = estimated_gas.saturating_add(estimated_gas / 2); - Ok(gas_with_buffer) -} diff --git a/crates/blacklight-contract-clients/src/heartbeat_manager.rs b/crates/blacklight-contract-clients/src/heartbeat_manager.rs index 7671b21..df9744e 100644 --- a/crates/blacklight-contract-clients/src/heartbeat_manager.rs +++ b/crates/blacklight-contract-clients/src/heartbeat_manager.rs @@ -1,9 +1,5 @@ -use crate::common::event_helper::{BlockRange, listen_events, listen_events_filtered}; use crate::htx::Htx; -use crate::{ - common::tx_submitter::TransactionSubmitter, - heartbeat_manager::HeartbeatManager::HeartbeatManagerInstance, -}; +use HeartbeatManager::HeartbeatManagerInstance; use alloy::{ primitives::{Address, B256, U256, keccak256}, providers::Provider, @@ -11,6 +7,8 @@ use alloy::{ sol_types::SolValue, }; use anyhow::{Context, Result, anyhow, bail}; +use contract_clients_common::event_helper::{BlockRange, listen_events, listen_events_filtered}; +use contract_clients_common::tx_submitter::TransactionSubmitter; use std::sync::Arc; use tokio::sync::Mutex; diff --git a/crates/blacklight-contract-clients/src/htx.rs b/crates/blacklight-contract-clients/src/htx.rs deleted file mode 100644 index f5bab46..0000000 --- a/crates/blacklight-contract-clients/src/htx.rs +++ /dev/null @@ -1,246 +0,0 @@ -use alloy::primitives::Bytes; -use serde::{Deserialize, Serialize}; -use serde_json::{Map, Value}; -use serde_with::{hex::Hex, serde_as}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkloadId { - pub current: String, - pub previous: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NilCcOperator { - pub id: u64, - pub name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Builder { - pub id: u64, - pub name: String, -} - -#[serde_as] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkloadMeasurement { - pub url: String, - pub artifacts_version: String, - pub cpus: u64, - pub gpus: u64, - #[serde_as(as = "Hex")] - pub docker_compose_hash: [u8; 32], -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuilderMeasurement { - pub url: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NillionHtxV1 { - pub workload_id: WorkloadId, - pub operator: Option, - pub builder: Option, - pub workload_measurement: WorkloadMeasurement, - pub builder_measurement: BuilderMeasurement, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "version", rename_all = "camelCase")] -pub enum NillionHtx { - /// The first HTX format version. - V1(NillionHtxV1), -} - -impl From for NillionHtx { - fn from(htx: NillionHtxV1) -> Self { - NillionHtx::V1(htx) - } -} - -// Phala HTX types -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhalaAttestData { - pub quote: String, - pub event_log: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PhalaHtxV1 { - pub app_compose: String, - pub attest_data: PhalaAttestData, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "version", rename_all = "camelCase")] -pub enum PhalaHtx { - /// The first HTX format version. - V1(PhalaHtxV1), -} - -// Unified HTX type that can represent both nilCC and Phala HTXs -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "provider", rename_all = "camelCase")] -pub enum Htx { - Nillion(NillionHtx), - Phala(PhalaHtx), -} - -impl From for Htx { - fn from(htx: NillionHtx) -> Self { - Htx::Nillion(htx) - } -} - -impl TryFrom<&Htx> for Bytes { - type Error = anyhow::Error; - - fn try_from(htx: &Htx) -> Result { - let json = canonicalize_json(&serde_json::to_value(htx)?); - let json = serde_json::to_string(&json)?; - Ok(Bytes::from(json.into_bytes())) - } -} - -fn canonicalize_json(value: &Value) -> Value { - match value { - Value::Object(map) => { - let mut sorted = Map::new(); - let mut keys: Vec<_> = map.keys().cloned().collect(); - keys.sort(); - for k in keys { - sorted.insert(k.clone(), canonicalize_json(&map[&k])); - } - Value::Object(sorted) - } - Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()), - _ => value.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_htx_deterministic_serialization() { - // Create an HTX - let htx = NillionHtxV1 { - workload_id: WorkloadId { - current: "1".into(), - previous: Some("0".into()), - }, - operator: Some(NilCcOperator { - id: 123, - name: "test-operator".to_string(), - }), - builder: Some(Builder { - id: 456, - name: "test-builder".to_string(), - }), - workload_measurement: WorkloadMeasurement { - url: "https://example.com/measurement".to_string(), - artifacts_version: "1.0.0".to_string(), - cpus: 8, - gpus: 2, - docker_compose_hash: [0; 32], - }, - builder_measurement: BuilderMeasurement { - url: "https://example.com/builder".to_string(), - }, - }; - let htx = Htx::Nillion(NillionHtx::V1(htx)); - - // Serialize the same HTX multiple times - let b1 = Bytes::try_from(&htx).unwrap(); - let b2 = Bytes::try_from(&htx).unwrap(); - let b3 = Bytes::try_from(&htx).unwrap(); - - assert_eq!(b1, b2); - assert_eq!(b2, b3); - - // Ensure all top level keys show up in sorted order - let json_str = String::from_utf8(b1.to_vec()).unwrap(); - let mut keys = [ - "builder", - "builder_measurement", - "operator", - "workload_id", - "workload_measurement", - ]; - keys.sort(); - let mut last_index = 0; - for key in keys { - let index = json_str - .find(&format!("\"{key}\"")) - .expect(&format!("key '{key}' not found")); - assert!(index > last_index); - last_index = index; - } - } - - #[test] - fn test_htx_phala_serialization() { - let htx_phala = PhalaHtxV1 { - app_compose: "test-compose-config".to_string(), - attest_data: PhalaAttestData { - quote: "test-quote-hex".to_string(), - event_log: r#"[{"event":"compose-hash","event_payload":"abc123"}]"#.to_string(), - }, - }; - - let json = serde_json::to_string(&htx_phala).unwrap(); - assert!(json.contains("\"app_compose\"")); - assert!(json.contains("\"attest_data\"")); - assert!(json.contains("test-compose-config")); - - let deserialized: PhalaHtxV1 = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized.app_compose, "test-compose-config"); - assert_eq!(deserialized.attest_data.quote, "test-quote-hex"); - } - - #[test] - fn test_deserialize_phala() { - let phala_json = r#"{ - "provider": "phala", - "version": "v1", - "app_compose": "test-compose", - "attest_data": { - "quote": "test-quote", - "event_log": "[]" - } - }"#; - - let htx: Htx = serde_json::from_str(phala_json).unwrap(); - let Htx::Phala(PhalaHtx::V1(htx)) = htx else { - panic!("not a phala HTX"); - }; - assert_eq!(htx.app_compose, "test-compose"); - } - - #[test] - fn test_deserialize_nillion() { - let nilcc_json = r#"{ - "provider": "nillion", - "version": "v1", - "workload_id": { - "current": "1", - "previous": null - }, - "workload_measurement": { - "url": "https://example.com/measurement", - "artifacts_version": "1.0.0", - "cpus": 8, - "gpus": 0, - "docker_compose_hash": "0000000000000000000000000000000000000000000000000000000000000000" - }, - "builder_measurement": { - "url": "https://example.com/builder" - } - }"#; - - let htx: Htx = serde_json::from_str(nilcc_json).unwrap(); - assert!(matches!(htx, Htx::Nillion(_)), "not a nillion HTX"); - } -} diff --git a/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs b/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs new file mode 100644 index 0000000..a214ef2 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs @@ -0,0 +1,62 @@ +//! ERC-8004 HTX - ABI-Encoded +//! +//! On-chain validation standard for agent validations. + +use alloy::primitives::{Address, B256, U256}; +use alloy::sol_types::{SolType, sol_data}; + +/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes. +/// +/// This format follows the ERC-8004 standard for on-chain agent validations. +/// +/// **ABI Format**: `abi.encode(validatorAddress, agentId, requestURI, requestHash)` +#[derive(Debug, Clone)] +pub struct Erc8004Htx { + /// Address of the validator performing the validation. + pub validator_address: Address, + /// Unique identifier for the agent being validated. + pub agent_id: U256, + /// URI pointing to the validation request data. + pub request_uri: String, + /// Hash of the validation request for integrity verification. + pub request_hash: B256, +} + +impl Erc8004Htx { + /// Decode ABI-encoded ERC-8004 validation data from raw bytes. + /// + /// # Errors + /// + /// Returns `Erc8004DecodeError` if the data cannot be decoded according to + /// the ERC-8004 ABI specification. + pub fn try_decode(data: &[u8]) -> Result { + type Erc8004Tuple = ( + sol_data::Address, + sol_data::Uint<256>, + sol_data::String, + sol_data::FixedBytes<32>, + ); + + let (validator_address, agent_id, request_uri, request_hash) = + Erc8004Tuple::abi_decode_params(data).map_err(|e| Erc8004DecodeError(e.to_string()))?; + + Ok(Self { + validator_address, + agent_id, + request_uri, + request_hash, + }) + } +} + +/// Error type for ERC-8004 HTX decoding failures. +#[derive(Debug)] +pub struct Erc8004DecodeError(pub String); + +impl std::fmt::Display for Erc8004DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "ERC-8004 decode error: {}", self.0) + } +} + +impl std::error::Error for Erc8004DecodeError {} diff --git a/crates/blacklight-contract-clients/src/htx/abi/mod.rs b/crates/blacklight-contract-clients/src/htx/abi/mod.rs new file mode 100644 index 0000000..d12c245 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/abi/mod.rs @@ -0,0 +1,36 @@ +//! ABI-Encoded HTX Formats +//! +//! This module contains all ABI-encoded HTX types. + +pub mod erc8004; + +pub use erc8004::*; + +/// ABI-encoded HTX wrapper for all ABI-based formats. +#[derive(Debug, Clone)] +pub enum AbiHtx { + Erc8004(Erc8004Htx), +} + +impl AbiHtx { + /// Try to decode ABI-encoded HTX data, attempting all known formats. + /// + /// # Errors + /// + /// Returns `AbiDecodeError::UnknownFormat` if the data doesn't match any + /// supported ABI format. + pub fn try_decode(data: &[u8]) -> Result { + if let Ok(erc8004_htx) = Erc8004Htx::try_decode(data) { + return Ok(AbiHtx::Erc8004(erc8004_htx)); + } + + Err(AbiDecodeError::UnknownFormat) + } +} + +/// Error type for ABI HTX decoding failures. +#[derive(Debug, thiserror::Error)] +pub enum AbiDecodeError { + #[error("Unknown ABI format: not valid ERC-8004 or other known ABI encoding")] + UnknownFormat, +} diff --git a/crates/blacklight-contract-clients/src/htx/json/mod.rs b/crates/blacklight-contract-clients/src/htx/json/mod.rs new file mode 100644 index 0000000..d67b536 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/json/mod.rs @@ -0,0 +1,24 @@ +//! JSON-Encoded HTX Formats +//! +//! This module contains all JSON-encoded HTX types from various providers. + +pub mod nillion; +pub mod phala; + +use serde::{Deserialize, Serialize}; + +pub use nillion::*; +pub use phala::*; + +/// JSON-serializable HTX wrapper for deserialization from JSON files. +/// +/// This enum encompasses all JSON-encoded HTX formats. Each variant corresponds +/// to a different confidential compute provider. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "provider", rename_all = "camelCase")] +pub enum JsonHtx { + /// Nillion confidential compute HTX. + Nillion(NillionHtx), + /// Phala confidential compute HTX. + Phala(PhalaHtx), +} diff --git a/crates/blacklight-contract-clients/src/htx/json/nillion.rs b/crates/blacklight-contract-clients/src/htx/json/nillion.rs new file mode 100644 index 0000000..118e813 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/json/nillion.rs @@ -0,0 +1,69 @@ +//! Nillion HTX - JSON-Encoded, Versioned +//! +//! TEE-based confidential compute workloads with hardware measurements. + +use serde::{Deserialize, Serialize}; +use serde_with::{hex::Hex, serde_as}; + +/// Nillion workload identifier with optional history tracking. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkloadId { + pub current: String, + pub previous: Option, +} + +/// Nillion confidential compute operator information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NilCcOperator { + pub id: u64, + pub name: String, +} + +/// Builder information for Nillion workloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Builder { + pub id: u64, + pub name: String, +} + +/// Measurement data for a Nillion workload including hardware requirements. +#[serde_as] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkloadMeasurement { + pub url: String, + pub artifacts_version: String, + pub cpus: u64, + pub gpus: u64, + #[serde_as(as = "Hex")] + pub docker_compose_hash: [u8; 32], +} + +/// Builder measurement data for Nillion workloads. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuilderMeasurement { + pub url: String, +} + +/// Nillion HTX Version 1 - Initial format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NillionHtxV1 { + pub workload_id: WorkloadId, + pub operator: Option, + pub builder: Option, + pub workload_measurement: WorkloadMeasurement, + pub builder_measurement: BuilderMeasurement, +} + +/// Versioned Nillion HTX format. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "version", rename_all = "camelCase")] +pub enum NillionHtx { + /// Version 1: Initial Nillion HTX format. + V1(NillionHtxV1), +} + +impl From for NillionHtx { + fn from(htx: NillionHtxV1) -> Self { + NillionHtx::V1(htx) + } +} diff --git a/crates/blacklight-contract-clients/src/htx/json/phala.rs b/crates/blacklight-contract-clients/src/htx/json/phala.rs new file mode 100644 index 0000000..53866c7 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/json/phala.rs @@ -0,0 +1,27 @@ +//! Phala HTX - JSON-Encoded, Versioned +//! +//! TEE-based confidential compute with attestation data. + +use serde::{Deserialize, Serialize}; + +/// Phala attestation data containing quote and event logs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhalaAttestData { + pub quote: String, + pub event_log: String, +} + +/// Phala HTX Version 1 - Initial format. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PhalaHtxV1 { + pub app_compose: String, + pub attest_data: PhalaAttestData, +} + +/// Versioned Phala HTX format. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "version", rename_all = "camelCase")] +pub enum PhalaHtx { + /// Version 1: Initial Phala HTX format. + V1(PhalaHtxV1), +} diff --git a/crates/blacklight-contract-clients/src/htx/mod.rs b/crates/blacklight-contract-clients/src/htx/mod.rs new file mode 100644 index 0000000..8461952 --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/mod.rs @@ -0,0 +1,374 @@ +//! # HTX Data Types +//! +//! This module provides a taxonomy of HTX formats organized by encoding type and version. +//! +//! ## Taxonomy +//! +//! ### JSON-Encoded HTXs ([`json`] module) +//! - **Nillion** ([`json::nillion`]): TEE-based confidential compute workloads +//! - V1: Initial version with workload measurements, operators, and builders +//! - **Phala** ([`json::phala`]): TEE-based confidential compute with attestation +//! - V1: Initial version with app composition and attestation data +//! +//! ### ABI-Encoded HTXs ([`abi`] module) +//! - **ERC-8004** ([`abi::erc8004`]): On-chain validation standard for agent validations +//! - Format: `abi.encode(validatorAddress, agentId, requestURI, requestHash)` +//! +//! ## Main Types +//! +//! - [`Htx`]: Unified enum for all HTX types to be used externally +//! - [`JsonHtx`]: JSON-serializable HTX formats (Nillion, Phala) +//! - [`AbiHtx`]: ABI-encoded HTX formats (ERC-8004) + +pub mod abi; +pub mod json; + +use alloy::primitives::Bytes; +use alloy::sol_types::SolValue; +use serde_json::{Map, Value}; + +pub use abi::*; +pub use json::*; + +/// Unified HTX type encompassing all supported formats. +/// +/// This enum provides a common interface for working with different HTX types. +/// The variants are organized by provider/standard for convenient pattern matching. +/// +/// The module structure organizes types by encoding (json/ and abi/ modules), +/// but the enum is flat for ease of use. +#[derive(Debug, Clone)] +pub enum Htx { + /// Nillion confidential compute HTX (JSON-encoded, versioned). + Nillion(json::NillionHtx), + /// Phala confidential compute HTX (JSON-encoded, versioned). + Phala(json::PhalaHtx), + /// ERC-8004 validation HTX (ABI-encoded). + Erc8004(abi::Erc8004Htx), +} + +impl From for Htx { + fn from(htx: json::NillionHtx) -> Self { + Htx::Nillion(htx) + } +} + +impl From for Htx { + fn from(htx: json::PhalaHtx) -> Self { + Htx::Phala(htx) + } +} + +impl From for Htx { + fn from(htx: abi::Erc8004Htx) -> Self { + Htx::Erc8004(htx) + } +} + +// Internal conversions for parsing +impl From for Htx { + fn from(htx: JsonHtx) -> Self { + match htx { + JsonHtx::Nillion(htx) => Htx::Nillion(htx), + JsonHtx::Phala(htx) => Htx::Phala(htx), + } + } +} + +impl From for Htx { + fn from(htx: AbiHtx) -> Self { + match htx { + AbiHtx::Erc8004(htx) => Htx::Erc8004(htx), + } + } +} + +impl Htx { + /// Parse HTX from raw bytes using auto-detection. + /// + /// This method attempts to parse the data in the following order: + /// 1. JSON-encoded HTX (Nillion or Phala) + /// 2. ABI-encoded HTX (ERC-8004) + /// + /// # Errors + /// + /// Returns `HtxParseError::UnknownFormat` if the data doesn't match any + /// supported HTX format. + pub fn try_parse(data: &[u8]) -> Result { + if let Ok(json_htx) = serde_json::from_slice::(data) { + return Ok(json_htx.into()); + } + + if let Ok(abi_htx) = AbiHtx::try_decode(data) { + return Ok(abi_htx.into()); + } + + Err(HtxParseError::UnknownFormat) + } +} + +/// Error type for HTX parsing failures. +#[derive(Debug, thiserror::Error)] +pub enum HtxParseError { + #[error("Unknown HTX format: not valid JSON or ABI-encoded")] + UnknownFormat, +} + +// ============================================================================ +// Serialization & Encoding +// ============================================================================ + +impl TryFrom<&Htx> for Bytes { + type Error = anyhow::Error; + + /// Convert an HTX to its wire format (bytes). + /// + /// - JSON-encoded HTXs (Nillion, Phala) are serialized as canonical JSON + /// - ABI-encoded HTXs (ERC-8004) are encoded according to their ABI specification + fn try_from(htx: &Htx) -> Result { + match htx { + Htx::Nillion(htx) => json_htx_to_bytes(JsonHtx::Nillion(htx.clone())), + Htx::Phala(htx) => json_htx_to_bytes(JsonHtx::Phala(htx.clone())), + Htx::Erc8004(htx) => abi_htx_to_bytes(&AbiHtx::Erc8004(htx.clone())), + } + } +} + +/// Serialize a JSON HTX to bytes with canonical JSON formatting. +/// +/// Canonical formatting ensures deterministic serialization by sorting +/// all object keys alphabetically. +fn json_htx_to_bytes(htx: JsonHtx) -> Result { + let json = canonicalize_json(&serde_json::to_value(htx)?); + let json = serde_json::to_string(&json)?; + Ok(Bytes::from(json.into_bytes())) +} + +/// Encode an ABI HTX to bytes according to its ABI specification. +fn abi_htx_to_bytes(htx: &AbiHtx) -> Result { + match htx { + AbiHtx::Erc8004(htx) => { + let tuple = ( + htx.validator_address, + htx.agent_id, + htx.request_uri.clone(), + htx.request_hash, + ); + Ok(Bytes::from(tuple.abi_encode())) + } + } +} + +/// Canonicalize JSON by recursively sorting all object keys. +/// +/// This ensures deterministic serialization regardless of insertion order. +fn canonicalize_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut sorted = Map::new(); + let mut keys: Vec<_> = map.keys().cloned().collect(); + keys.sort(); + for k in keys { + sorted.insert(k.clone(), canonicalize_json(&map[&k])); + } + Value::Object(sorted) + } + Value::Array(arr) => Value::Array(arr.iter().map(canonicalize_json).collect()), + _ => value.clone(), + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------------ + // JSON-Encoded HTX Tests + // ------------------------------------------------------------------------ + + #[test] + fn test_nillion_deterministic_serialization() { + use json::*; + + // Create an HTX + let htx = NillionHtxV1 { + workload_id: WorkloadId { + current: "1".into(), + previous: Some("0".into()), + }, + operator: Some(NilCcOperator { + id: 123, + name: "test-operator".to_string(), + }), + builder: Some(Builder { + id: 456, + name: "test-builder".to_string(), + }), + workload_measurement: WorkloadMeasurement { + url: "https://example.com/measurement".to_string(), + artifacts_version: "1.0.0".to_string(), + cpus: 8, + gpus: 2, + docker_compose_hash: [0; 32], + }, + builder_measurement: BuilderMeasurement { + url: "https://example.com/builder".to_string(), + }, + }; + let htx = Htx::Nillion(NillionHtx::V1(htx)); + + // Serialize the same HTX multiple times + let b1 = Bytes::try_from(&htx).unwrap(); + let b2 = Bytes::try_from(&htx).unwrap(); + let b3 = Bytes::try_from(&htx).unwrap(); + + assert_eq!(b1, b2); + assert_eq!(b2, b3); + + // Ensure all top level keys show up in sorted order + let json_str = String::from_utf8(b1.to_vec()).unwrap(); + let mut keys = [ + "builder", + "builder_measurement", + "operator", + "workload_id", + "workload_measurement", + ]; + keys.sort(); + let mut last_index = 0; + for key in keys { + let index = json_str + .find(&format!("\"{key}\"")) + .expect(&format!("key '{key}' not found")); + assert!(index > last_index); + last_index = index; + } + } + + #[test] + fn test_nillion_deserialization() { + let nilcc_json = r#"{ + "provider": "nillion", + "version": "v1", + "workload_id": { + "current": "1", + "previous": null + }, + "workload_measurement": { + "url": "https://example.com/measurement", + "artifacts_version": "1.0.0", + "cpus": 8, + "gpus": 0, + "docker_compose_hash": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "builder_measurement": { + "url": "https://example.com/builder" + } + }"#; + + let htx: JsonHtx = serde_json::from_str(nilcc_json).unwrap(); + assert!(matches!(htx, JsonHtx::Nillion(_)), "not a nillion HTX"); + } + + #[test] + fn test_phala_serialization() { + use json::*; + + let htx_phala = PhalaHtxV1 { + app_compose: "test-compose-config".to_string(), + attest_data: PhalaAttestData { + quote: "test-quote-hex".to_string(), + event_log: r#"[{"event":"compose-hash","event_payload":"abc123"}]"#.to_string(), + }, + }; + + let json = serde_json::to_string(&htx_phala).unwrap(); + assert!(json.contains("\"app_compose\"")); + assert!(json.contains("\"attest_data\"")); + assert!(json.contains("test-compose-config")); + + let deserialized: PhalaHtxV1 = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.app_compose, "test-compose-config"); + assert_eq!(deserialized.attest_data.quote, "test-quote-hex"); + } + + #[test] + fn test_phala_deserialization() { + let phala_json = r#"{ + "provider": "phala", + "version": "v1", + "app_compose": "test-compose", + "attest_data": { + "quote": "test-quote", + "event_log": "[]" + } + }"#; + + let htx: JsonHtx = serde_json::from_str(phala_json).unwrap(); + let JsonHtx::Phala(PhalaHtx::V1(htx)) = htx else { + panic!("not a phala HTX"); + }; + assert_eq!(htx.app_compose, "test-compose"); + } + + // ------------------------------------------------------------------------ + // ABI-Encoded HTX Tests + // ------------------------------------------------------------------------ + + #[test] + fn test_erc8004_decode() { + use alloy::primitives::Address; + + // Test data: abi.encode(0x5fc8d32690cc91d4c39d9d3abcbd16989f875707, 0, "https://api.nilai.nillion.network/", 0xa6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac) + let raw_hex = "0000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f87570700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080a6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac000000000000000000000000000000000000000000000000000000000000002268747470733a2f2f6170692e6e696c61692e6e696c6c696f6e2e6e6574776f726b2f000000000000000000000000000000000000000000000000000000000000"; + let data = alloy::hex::decode(raw_hex).unwrap(); + + let htx = Erc8004Htx::try_decode(&data).expect("should decode ERC-8004 HTX"); + assert_eq!( + htx.validator_address, + "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707" + .parse::
() + .unwrap() + ); + assert_eq!(htx.agent_id, alloy::primitives::U256::ZERO); + assert_eq!(htx.request_uri, "https://api.nilai.nillion.network/"); + } + + #[test] + fn test_htx_parse_json() { + let json_data = r#"{ + "provider": "nillion", + "version": "v1", + "workload_id": { + "current": "1", + "previous": null + }, + "workload_measurement": { + "url": "https://example.com/measurement", + "artifacts_version": "1.0.0", + "cpus": 8, + "gpus": 0, + "docker_compose_hash": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "builder_measurement": { + "url": "https://example.com/builder" + } + }"#; + + let htx = Htx::try_parse(json_data.as_bytes()).unwrap(); + assert!(matches!(htx, Htx::Nillion(_))); + } + + #[test] + fn test_htx_parse_abi() { + let raw_hex = "0000000000000000000000005fc8d32690cc91d4c39d9d3abcbd16989f87570700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080a6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac000000000000000000000000000000000000000000000000000000000000002268747470733a2f2f6170692e6e696c61692e6e696c6c696f6e2e6e6574776f726b2f000000000000000000000000000000000000000000000000000000000000"; + let data = alloy::hex::decode(raw_hex).unwrap(); + + let htx = Htx::try_parse(&data).unwrap(); + assert!(matches!(htx, Htx::Erc8004(_))); + } +} diff --git a/crates/blacklight-contract-clients/src/lib.rs b/crates/blacklight-contract-clients/src/lib.rs index 8d046b0..4708189 100644 --- a/crates/blacklight-contract-clients/src/lib.rs +++ b/crates/blacklight-contract-clients/src/lib.rs @@ -1,7 +1,6 @@ use alloy::primitives::Address; pub mod blacklight_client; -pub mod common; pub mod heartbeat_manager; pub mod htx; pub mod nil_token; @@ -136,7 +135,7 @@ impl ContractConfig { #[cfg(test)] mod tests { use super::*; - use crate::htx::{ + use crate::htx::json::{ Builder, BuilderMeasurement, NilCcOperator, NillionHtx, NillionHtxV1, WorkloadId, WorkloadMeasurement, }; diff --git a/crates/blacklight-contract-clients/src/nil_token.rs b/crates/blacklight-contract-clients/src/nil_token.rs index a8aecd5..38ad0b4 100644 --- a/crates/blacklight-contract-clients/src/nil_token.rs +++ b/crates/blacklight-contract-clients/src/nil_token.rs @@ -1,13 +1,11 @@ -use crate::{ - ContractConfig, - common::{event_helper::listen_events, tx_submitter::TransactionSubmitter}, -}; use alloy::{ primitives::{Address, B256, U256}, providers::Provider, sol, }; use anyhow::Result; +use contract_clients_common::event_helper::listen_events; +use contract_clients_common::tx_submitter::TransactionSubmitter; use std::{convert::Infallible, sync::Arc}; use tokio::sync::Mutex; @@ -33,6 +31,8 @@ sol!( // Optional: bring the instance & events into scope use NilToken::NilTokenInstance; +use crate::ContractConfig; + /// WebSocket-based client for interacting with the NilToken ERC20 contract #[derive(Clone)] pub struct NilTokenClient { diff --git a/crates/blacklight-contract-clients/src/protocol_config.rs b/crates/blacklight-contract-clients/src/protocol_config.rs index cfc7035..ee12f4c 100644 --- a/crates/blacklight-contract-clients/src/protocol_config.rs +++ b/crates/blacklight-contract-clients/src/protocol_config.rs @@ -1,10 +1,10 @@ -use crate::common::tx_submitter::TransactionSubmitter; use alloy::{ primitives::{Address, B256}, providers::Provider, sol, }; use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; use std::sync::Arc; use tokio::sync::Mutex; diff --git a/crates/blacklight-contract-clients/src/staking_operators.rs b/crates/blacklight-contract-clients/src/staking_operators.rs index bab70c4..ea76b9e 100644 --- a/crates/blacklight-contract-clients/src/staking_operators.rs +++ b/crates/blacklight-contract-clients/src/staking_operators.rs @@ -1,10 +1,11 @@ -use crate::{ContractConfig, common::tx_submitter::TransactionSubmitter}; +use crate::ContractConfig; use alloy::{ primitives::{Address, B256, U256}, providers::Provider, sol, }; use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; use futures_util::future::join_all; use std::sync::Arc; use tokio::sync::Mutex; diff --git a/crates/contract-clients-common/Cargo.toml b/crates/contract-clients-common/Cargo.toml new file mode 100644 index 0000000..42e75f7 --- /dev/null +++ b/crates/contract-clients-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "contract-clients-common" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } +alloy-provider = { version = "1.1", features = ["ws"] } +futures-util = "0.3" +tokio = { version = "1.49", features = ["sync"] } +tracing = "0.1" diff --git a/crates/blacklight-contract-clients/src/common/errors.rs b/crates/contract-clients-common/src/errors.rs similarity index 75% rename from crates/blacklight-contract-clients/src/common/errors.rs rename to crates/contract-clients-common/src/errors.rs index 64488d5..fc25638 100644 --- a/crates/blacklight-contract-clients/src/common/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -14,28 +14,16 @@ //! - Selector: `0x4e487b71` //! - Includes overflow, division by zero, array bounds, etc. //! -//! 3. **Custom Contract Errors** - Gas-efficient custom errors from contract ABIs -//! - Currently supports: `StakingOperatorsErrors`, we need to add more for the custom contracts such as HeartbeatManagerErrors +//! 3. **Custom Contract Errors** - Extensible via [`decode_revert_with_custom`] //! - Each error has a unique 4-byte selector derived from its signature +//! - Consumers can provide their own custom error decoders //! //! ## Usage Flow //! //! ```text //! Transaction reverts → RPC returns error with hex data → //! decode_any_error() → try_extract_from_string() → decode_revert() → -//! → "blacklight: HTX already exists" (human-readable!) -//! ``` -//! -//! ## Example -//! -//! Instead of seeing: -//! ```text -//! error: execution reverted: 0x08c379a0000000... -//! ``` -//! -//! You now see: -//! ```text -//! error: blacklight: HTX already exists +//! → "contract: request already exists" (human-readable!) //! ``` //! //! ## Main Entry Points @@ -43,6 +31,7 @@ //! - [`decode_any_error`] - Generic entry point for any error type //! - [`extract_revert_from_contract_error`] - For Alloy's `ContractError` type //! - [`decode_revert`] - For raw `Bytes` revert data +//! - [`decode_revert_with_custom`] - For raw `Bytes` with custom error decoder use alloy::{ contract::Error as ContractError, hex, primitives::Bytes, sol, sol_types::SolInterface, @@ -72,28 +61,6 @@ sol! { } } -// ============================================================================ -// Contract-specific Errors - Extracted from ABIs -// ============================================================================ -// -// Custom Solidity errors are more gas-efficient than `require()` with strings. -// The `sol!` macro in the contract binding modules automatically generates -// Rust types for all custom errors defined in the contract ABI. -// -// To add support for a new contract's errors: -// 1. Ensure the contract module uses `sol!` to generate bindings -// 2. Re-export the errors enum here: `pub use super::module::Contract::ContractErrors;` -// 3. Add a case in `decode_revert()` to try decoding with the new error type -// 4. Add a `format_X_error()` function to provide human-readable messages - -/// Re-export StakingOperators custom errors from the contract bindings. -/// These are automatically generated by the `sol!` macro from the contract ABI. -pub use crate::staking_operators::StakingOperators::StakingOperatorsErrors; - -// Note: HeartbeatManager currently uses require() with string messages, not custom errors. -// When custom errors are added to the contract, they will be automatically available -// via HeartbeatManager::HeartbeatManagerErrors and should be added here. - // ============================================================================ // DecodedRevert Enum - The Result of Decoding // ============================================================================ @@ -106,7 +73,7 @@ pub use crate::staking_operators::StakingOperators::StakingOperatorsErrors; /// |---------|----------------| /// | `ErrorString` | `require()` failed with a message | /// | `Panic` | `assert()` failed or arithmetic error | -/// | `StakingError` | Custom error from StakingOperators contract | +/// | `CustomError` | Custom error decoded by consumer-provided decoder | /// | `RawRevert` | We got hex data but couldn't decode it | /// | `NoRevertData` | No revert data at all (unusual) | #[derive(Debug, Clone)] @@ -120,9 +87,9 @@ pub enum DecodedRevert { /// See [`panic_reason`] for code meanings. Panic(u64), - /// Custom error from the StakingOperators contract. - /// These are gas-efficient errors defined in the contract's ABI. - StakingError(String), + /// Custom error decoded by a consumer-provided decoder. + /// The string contains a human-readable description of the error. + CustomError(String), /// Raw revert data that couldn't be decoded by any known error type. /// Contains the hex bytes so the user can manually debug. @@ -138,7 +105,7 @@ impl std::fmt::Display for DecodedRevert { match self { DecodedRevert::ErrorString(msg) => write!(f, "{}", msg), DecodedRevert::Panic(code) => write!(f, "Panic({}): {}", code, panic_reason(*code)), - DecodedRevert::StakingError(msg) => write!(f, "{}", msg), + DecodedRevert::CustomError(msg) => write!(f, "{}", msg), DecodedRevert::RawRevert(data) => write!(f, "Raw revert data: {}", data), DecodedRevert::NoRevertData(details) => write!(f, "No revert data ({})", details), } @@ -168,7 +135,7 @@ impl std::fmt::Display for DecodedRevert { /// | 0x32 | Array index out of bounds | /// | 0x41 | Memory allocation overflow | /// | 0x51 | Zero-initialized function pointer call | -fn panic_reason(code: u64) -> &'static str { +pub fn panic_reason(code: u64) -> &'static str { match code { 0x00 => "generic compiler panic", 0x01 => "assertion failed", @@ -193,12 +160,34 @@ fn panic_reason(code: u64) -> &'static str { /// This function attempts to decode the raw bytes in the following order: /// 1. **Standard `Error(string)`** - Most common from `require()` /// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow -/// 3. **Custom `StakingOperatorsErrors`** - Contract-specific errors +/// 3. **Fallback** - Return the raw hex so user can debug +/// +/// For custom error decoding, use [`decode_revert_with_custom`] instead. +/// +/// # Arguments +/// +/// * `data` - Raw ABI-encoded revert data from the EVM +/// +/// # Returns +/// +/// A [`DecodedRevert`] variant representing the decoded error. +pub fn decode_revert(data: &Bytes) -> DecodedRevert { + decode_revert_with_custom(data, |_| None) +} + +/// Decode raw revert data bytes with a custom error decoder. +/// +/// This function attempts to decode the raw bytes in the following order: +/// 1. **Standard `Error(string)`** - Most common from `require()` +/// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow +/// 3. **Custom errors** - Via the provided `custom_decoder` /// 4. **Fallback** - Return the raw hex so user can debug /// /// # Arguments /// /// * `data` - Raw ABI-encoded revert data from the EVM +/// * `custom_decoder` - A function that attempts to decode custom contract errors. +/// Returns `Some(DecodedRevert)` if the error was recognized, `None` otherwise. /// /// # Returns /// @@ -207,11 +196,20 @@ fn panic_reason(code: u64) -> &'static str { /// # Example /// /// ```ignore -/// let revert_data = Bytes::from(hex::decode("08c379a0...").unwrap()); -/// let decoded = decode_revert(&revert_data); -/// println!("Error: {}", decoded); // "blacklight: HTX already exists" +/// use contract_clients_common::errors::{decode_revert_with_custom, DecodedRevert}; +/// +/// let decoded = decode_revert_with_custom(&data, |bytes| { +/// if let Ok(err) = MyContractErrors::abi_decode(bytes) { +/// Some(DecodedRevert::CustomError(format!("{:?}", err))) +/// } else { +/// None +/// } +/// }); /// ``` -pub fn decode_revert(data: &Bytes) -> DecodedRevert { +pub fn decode_revert_with_custom(data: &Bytes, custom_decoder: F) -> DecodedRevert +where + F: FnOnce(&Bytes) -> Option, +{ // Empty revert data is unusual - contracts normally include some data if data.is_empty() { return DecodedRevert::NoRevertData("empty revert data".to_string()); @@ -231,11 +229,9 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { } } - // Step 2: Try to decode as StakingOperators custom errors - // Each custom error has a unique 4-byte selector derived from its signature - if let Ok(err) = StakingOperatorsErrors::abi_decode(data) { - let msg = format_staking_error(&err); - return DecodedRevert::StakingError(msg); + // Step 2: Try custom error decoder + if let Some(decoded) = custom_decoder(data) { + return decoded; } // Step 3: Unknown error - return raw bytes so user can debug @@ -243,39 +239,6 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { DecodedRevert::RawRevert(data.clone()) } -// ============================================================================ -// Human-Readable Error Formatting -// ============================================================================ - -/// Format a StakingOperators custom error into a human-readable message. -/// -/// This function provides user-friendly descriptions for each custom error -/// defined in the StakingOperators contract. -/// -/// # Arguments -/// -/// * `err` - The decoded StakingOperators error variant -/// -/// # Returns -/// -/// A human-readable error message string. -fn format_staking_error(err: &StakingOperatorsErrors) -> String { - match err { - StakingOperatorsErrors::DifferentStaker(_) => "Different staker".to_string(), - StakingOperatorsErrors::InsufficientStake(_) => "Insufficient stake".to_string(), - StakingOperatorsErrors::NoStake(_) => "No stake".to_string(), - StakingOperatorsErrors::NoUnbonding(_) => "No unbonding request".to_string(), - StakingOperatorsErrors::NotActive(_) => "Operator not active".to_string(), - StakingOperatorsErrors::NotReady(_) => "Unbonding period not ready".to_string(), - StakingOperatorsErrors::NotStaker(_) => "Not a staker".to_string(), - StakingOperatorsErrors::OperatorJailed(_) => "Operator is jailed".to_string(), - StakingOperatorsErrors::PendingUnbonding(_) => "Pending unbonding exists".to_string(), - StakingOperatorsErrors::UnbondingExists(_) => "Unbonding already exists".to_string(), - StakingOperatorsErrors::ZeroAddress(_) => "Zero address not allowed".to_string(), - StakingOperatorsErrors::ZeroAmount(_) => "Zero amount not allowed".to_string(), - } -} - // ============================================================================ // Alloy ContractError Extraction // ============================================================================ @@ -303,10 +266,24 @@ fn format_staking_error(err: &StakingOperatorsErrors) -> String { /// /// A [`DecodedRevert`] with the decoded error or context about why decoding failed. pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRevert { + extract_revert_from_contract_error_with_custom(error, |_| None) +} + +/// Extract and decode revert data from an Alloy [`ContractError`] with custom decoder. +/// +/// Same as [`extract_revert_from_contract_error`] but allows providing a custom +/// error decoder for contract-specific errors. +pub fn extract_revert_from_contract_error_with_custom( + error: &ContractError, + custom_decoder: F, +) -> DecodedRevert +where + F: Fn(&Bytes) -> Option, +{ match error { // TransportError is the most common - it wraps RPC error responses ContractError::TransportError(transport_err) => { - extract_revert_from_transport_error(transport_err) + extract_revert_from_transport_error_with_custom(transport_err, custom_decoder) } // ABI errors happen when encoding/decoding fails (rare) ContractError::AbiError(abi_err) => { @@ -316,7 +293,8 @@ pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRever // This is a fallback - ideally we'd handle all variants explicitly _ => { let debug_str = format!("{:?}", error); - if let Some(decoded) = try_extract_from_string(&debug_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) + { decoded } else { DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) @@ -338,7 +316,13 @@ pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRever /// # Returns /// /// A [`DecodedRevert`] with the decoded error data. -fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert { +fn extract_revert_from_transport_error_with_custom( + error: &TransportError, + custom_decoder: F, +) -> DecodedRevert +where + F: Fn(&Bytes) -> Option, +{ match error { TransportError::ErrorResp(err_resp) => { // The error response may contain revert data in the `data` field @@ -352,7 +336,7 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert if let Some(hex_data) = data_str.strip_prefix("0x") && let Ok(bytes) = hex::decode(hex_data) { - return decode_revert(&Bytes::from(bytes)); + return decode_revert_with_custom(&Bytes::from(bytes), |b| custom_decoder(b)); } // If not hex, include the raw data for debugging return DecodedRevert::NoRevertData(format!("Error data: {}", data_str)); @@ -363,7 +347,7 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert // For other transport errors (timeout, connection, etc.), try string extraction _ => { let err_str = error.to_string(); - if let Some(decoded) = try_extract_from_string(&err_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&err_str, &custom_decoder) { decoded } else { DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) @@ -396,7 +380,18 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert /// # Returns /// /// `Some(DecodedRevert)` if hex data was found and decoded, `None` otherwise. -fn try_extract_from_string(error_str: &str) -> Option { +pub fn try_extract_from_string(error_str: &str) -> Option { + try_extract_from_string_with_custom(error_str, &|_| None) +} + +/// Try to extract revert data from an error string with a custom decoder. +fn try_extract_from_string_with_custom( + error_str: &str, + custom_decoder: &F, +) -> Option +where + F: Fn(&Bytes) -> Option, +{ // Patterns that indicate hex revert data follows const PATTERNS: &[&str] = &[ "execution reverted: 0x", @@ -438,14 +433,16 @@ fn try_extract_from_string(error_str: &str) -> Option { if hex_str.len() >= 10 { let without_prefix = hex_str.strip_prefix("0x").unwrap_or(hex_str); if let Ok(bytes) = hex::decode(without_prefix) { - return Some(decode_revert(&Bytes::from(bytes))); + return Some(decode_revert_with_custom(&Bytes::from(bytes), |b| { + custom_decoder(b) + })); } } } } // Special case: plain text error message after "execution reverted:" - // Some RPC providers return: `execution reverted: blacklight: HTX already exists` + // Some RPC providers return: `execution reverted: contract: request already exists` if error_str.contains("execution reverted") && let Some(idx) = error_str.find("execution reverted:") { @@ -490,16 +487,27 @@ fn try_extract_from_string(error_str: &str) -> Option { /// log::error!("Transaction failed: {}", decoded); /// ``` pub fn decode_any_error(error: &E) -> DecodedRevert { + decode_any_error_with_custom(error, |_| None) +} + +/// Decode ANY error with a custom error decoder. +/// +/// Same as [`decode_any_error`] but allows providing a custom error decoder. +pub fn decode_any_error_with_custom(error: &E, custom_decoder: F) -> DecodedRevert +where + E: std::fmt::Display + std::fmt::Debug, + F: Fn(&Bytes) -> Option, +{ let error_str = error.to_string(); let debug_str = format!("{:?}", error); // First try the Display representation - if let Some(decoded) = try_extract_from_string(&error_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&error_str, &custom_decoder) { return decoded; } // Then try the Debug representation (often has more details like struct fields) - if let Some(decoded) = try_extract_from_string(&debug_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) { return decoded; } @@ -530,13 +538,13 @@ mod tests { // "blacklight: unknown HTX" encoded as Error(string) // Selector: 08c379a0 // Offset: 0000...0020 (32 bytes) - // Length: 0000...0012 (18 bytes = "blacklight: unknown HTX".len()) - // Data: 4e696c41563a20756e6b6e6f776e20485458 + padding + // Length: 0000...0017 (23 bytes = "blacklight: unknown HTX".len()) + // Data: 626c61636b6c696768743a20756e6b6e6f776e20485458 + padding let data = hex::decode( "08c379a0\ 0000000000000000000000000000000000000000000000000000000000000020\ - 0000000000000000000000000000000000000000000000000000000000000012\ - 4e696c41563a20756e6b6e6f776e204854580000000000000000000000000000", + 0000000000000000000000000000000000000000000000000000000000000017\ + 626c61636b6c696768743a20756e6b6e6f776e20485458000000000000000000", ) .unwrap(); @@ -544,7 +552,7 @@ mod tests { match decoded { DecodedRevert::ErrorString(msg) => { - assert_eq!(msg, "NilAV: unknown HTX"); + assert_eq!(msg, "blacklight: unknown HTX"); } _ => panic!("Expected ErrorString, got {:?}", decoded), } @@ -576,25 +584,6 @@ mod tests { } } - /// Test decoding a custom StakingOperators error. - /// - /// Custom errors with no parameters only need the 4-byte selector. - /// Selector for InsufficientStake(): cast sig "InsufficientStake()" = 0xf1bc94d2 - #[test] - fn test_decode_staking_error() { - // InsufficientStake() error - selector only, no params - let data = hex::decode("f1bc94d2").unwrap(); - - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::StakingError(msg) => { - assert_eq!(msg, "Insufficient stake"); - } - _ => panic!("Expected StakingError, got {:?}", decoded), - } - } - /// Test the Display implementation for all DecodedRevert variants. #[test] fn test_display() { @@ -604,19 +593,20 @@ mod tests { let panic = DecodedRevert::Panic(1); assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); - let staking = DecodedRevert::StakingError("No stake".to_string()); - assert_eq!(format!("{}", staking), "No stake"); + let custom = DecodedRevert::CustomError("Custom error".to_string()); + assert_eq!(format!("{}", custom), "Custom error"); } /// Test extracting revert data from various error string formats. #[test] fn test_try_extract_from_string() { // Test with "execution reverted: 0x..." format (common from geth/anvil) - let error_msg = "execution reverted: 0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001a4e696c41563a204854582020616c72656164792065786973747300000000000000"; + // "blacklight: HTX already exists" (31 bytes = 0x1f) + let error_msg = "execution reverted: 0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001f626c61636b6c696768743a204854582020616c7265616479206578697374730000"; let decoded = try_extract_from_string(error_msg); assert!(decoded.is_some()); if let Some(DecodedRevert::ErrorString(msg)) = decoded { - assert!(msg.contains("NilAV")); + assert!(msg.contains("blacklight")); } // Test with raw hex selector embedded in string @@ -632,4 +622,25 @@ mod tests { assert_eq!(panic_reason(0x11), "arithmetic overflow/underflow"); assert_eq!(panic_reason(0x12), "division by zero"); } + + /// Test custom error decoder. + #[test] + fn test_custom_decoder() { + // Some unknown error selector + let data = hex::decode("deadbeef").unwrap(); + + // Without custom decoder - should return RawRevert + let decoded = decode_revert(&Bytes::from(data.clone())); + assert!(matches!(decoded, DecodedRevert::RawRevert(_))); + + // With custom decoder that recognizes this selector + let decoded = decode_revert_with_custom(&Bytes::from(data), |bytes| { + if bytes.starts_with(&[0xde, 0xad, 0xbe, 0xef]) { + Some(DecodedRevert::CustomError("Known custom error".to_string())) + } else { + None + } + }); + assert!(matches!(decoded, DecodedRevert::CustomError(msg) if msg == "Known custom error")); + } } diff --git a/crates/blacklight-contract-clients/src/common/event_helper.rs b/crates/contract-clients-common/src/event_helper.rs similarity index 98% rename from crates/blacklight-contract-clients/src/common/event_helper.rs rename to crates/contract-clients-common/src/event_helper.rs index 3e2e56a..e4fd7e5 100644 --- a/crates/blacklight-contract-clients/src/common/event_helper.rs +++ b/crates/contract-clients-common/src/event_helper.rs @@ -6,7 +6,7 @@ //! ## Usage //! //! ```ignore -//! use crate::contract_client::event_helper::BlockRange; +//! use contract_clients_common::event_helper::BlockRange; //! //! // Query with block range //! let range = BlockRange::last_n_blocks(1000); diff --git a/crates/contract-clients-common/src/lib.rs b/crates/contract-clients-common/src/lib.rs new file mode 100644 index 0000000..72f1fda --- /dev/null +++ b/crates/contract-clients-common/src/lib.rs @@ -0,0 +1,57 @@ +//! # Contract Clients Common +//! +//! Shared utilities for Ethereum contract clients using Alloy. +//! +//! This crate provides: +//! - **Error decoding**: Human-readable Solidity revert errors +//! - **Event helpers**: Utilities for event listening and querying +//! - **Transaction submission**: Reliable transaction submission with gas estimation +//! +//! ## Usage +//! +//! ```ignore +//! use contract_clients_common::{ +//! errors::{decode_any_error, DecodedRevert}, +//! event_helper::BlockRange, +//! tx_submitter::TransactionSubmitter, +//! }; +//! ``` + +use alloy::{ + contract::{CallBuilder, CallDecoder}, + providers::Provider, +}; +use anyhow::anyhow; + +use crate::errors::decode_any_error; + +pub mod errors; +pub mod event_helper; +pub mod provider_context; +pub mod tx_submitter; + +pub use provider_context::ProviderContext; + +/// Estimate gas for a contract call with a 50% buffer. +/// +/// This is useful for ensuring transactions have enough gas headroom, +/// especially for complex operations that may use more gas than estimated. +/// +/// # Arguments +/// +/// * `call` - The contract call to estimate gas for +/// +/// # Returns +/// +/// The estimated gas with a 50% buffer added. +pub async fn overestimate_gas( + call: &CallBuilder, +) -> anyhow::Result { + // Estimate gas and add a 50% buffer + let estimated_gas = call.estimate_gas().await.map_err(|e| { + let decoded = decode_any_error(&e); + anyhow!("failed to estimate gas: {decoded}") + })?; + let gas_with_buffer = estimated_gas.saturating_add(estimated_gas / 2); + Ok(gas_with_buffer) +} diff --git a/crates/contract-clients-common/src/provider_context.rs b/crates/contract-clients-common/src/provider_context.rs new file mode 100644 index 0000000..4384573 --- /dev/null +++ b/crates/contract-clients-common/src/provider_context.rs @@ -0,0 +1,115 @@ +use alloy::{ + network::{Ethereum, EthereumWallet, NetworkWallet}, + primitives::{Address, B256, TxKind, U256}, + providers::{DynProvider, Provider, ProviderBuilder, WsConnect}, + rpc::types::TransactionRequest, + signers::local::PrivateKeySigner, +}; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Shared provider context that holds an Alloy provider, wallet, and transaction lock. +/// +/// When multiple contract clients (e.g. `BlacklightClient` and `Erc8004Client`) are +/// instantiated with the same private key, they should share a single `ProviderContext` +/// to avoid nonce conflicts. Cloning a `ProviderContext` shares the underlying state. +#[derive(Clone)] +pub struct ProviderContext { + provider: DynProvider, + wallet: EthereumWallet, + tx_lock: Arc>, +} + +impl ProviderContext { + /// Create a new provider context with a WebSocket connection. + pub async fn new(rpc_url: &str, private_key: &str) -> anyhow::Result { + Self::with_ws_retries(rpc_url, private_key, None).await + } + + /// Create a new provider context with configurable WebSocket retry count. + /// + /// If `max_ws_retries` is `None`, the default retry behaviour from Alloy is used + /// (no explicit retry limit set). + pub async fn with_ws_retries( + rpc_url: &str, + private_key: &str, + max_ws_retries: Option, + ) -> anyhow::Result { + let ws_url = rpc_url + .replace("http://", "ws://") + .replace("https://", "wss://"); + + let mut ws = WsConnect::new(ws_url); + if let Some(retries) = max_ws_retries { + ws = ws.with_max_retries(retries); + } + + let signer: PrivateKeySigner = private_key.parse::()?; + let wallet = EthereumWallet::from(signer); + + let provider: DynProvider = ProviderBuilder::new() + .wallet(wallet.clone()) + .with_simple_nonce_management() + .with_gas_estimation() + .connect_ws(ws) + .await? + .erased(); + + let tx_lock = Arc::new(Mutex::new(())); + + Ok(Self { + provider, + wallet, + tx_lock, + }) + } + + /// Reference to the underlying provider. + pub fn provider(&self) -> &DynProvider { + &self.provider + } + + /// Reference to the wallet. + pub fn wallet(&self) -> &EthereumWallet { + &self.wallet + } + + /// Shared transaction lock. + pub fn tx_lock(&self) -> Arc> { + self.tx_lock.clone() + } + + /// Get the default signer address from the wallet. + pub fn signer_address(&self) -> Address { + >::default_signer_address(&self.wallet) + } + + /// Get the ETH balance of the signer address. + pub async fn get_balance(&self) -> anyhow::Result { + let address = self.signer_address(); + Ok(self.provider.get_balance(address).await?) + } + + /// Get the ETH balance of a specific address. + pub async fn get_balance_of(&self, address: Address) -> anyhow::Result { + Ok(self.provider.get_balance(address).await?) + } + + /// Send ETH to an address. + pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { + let tx = TransactionRequest { + to: Some(TxKind::Call(to)), + value: Some(amount), + max_priority_fee_per_gas: Some(1), + ..Default::default() + }; + + let tx_hash = self.provider.send_transaction(tx).await?.watch().await?; + Ok(tx_hash) + } + + /// Get the current block number. + pub async fn get_block_number(&self) -> anyhow::Result { + Ok(self.provider.get_block_number().await?) + } +} diff --git a/crates/blacklight-contract-clients/src/common/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs similarity index 92% rename from crates/blacklight-contract-clients/src/common/tx_submitter.rs rename to crates/contract-clients-common/src/tx_submitter.rs index 11a7ecf..dbba675 100644 --- a/crates/blacklight-contract-clients/src/common/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -1,9 +1,9 @@ -use crate::common::overestimate_gas; +use crate::overestimate_gas; use alloy::{ consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, rpc::types::TransactionReceipt, sol_types::SolInterface, }; -use anyhow::{Result, anyhow}; +use anyhow::{Result, bail}; use std::fmt::Debug; use std::marker::PhantomData; use std::sync::Arc; @@ -34,7 +34,7 @@ impl TransactionSubmitter { // Pre-simulate to catch reverts with proper error messages if let Err(e) = call.call().await { let e = self.decode_error(e); - return Err(anyhow!("{method} reverted: {e}")); + bail!("{method} reverted: {e}"); } let (call, gas_limit) = match self.gas_buffer { @@ -58,10 +58,13 @@ impl TransactionSubmitter { // Acquire lock and send let _guard = self.tx_lock.lock().await; - let pending = call.send().await.map_err(|e| { - let e = self.decode_error(e); - anyhow!("{method} failed to send: {e}") - })?; + let pending = match call.send().await { + Ok(pending) => pending, + Err(e) => { + let e = self.decode_error(e); + bail!("{method} failed to send: {e}"); + } + }; // Wait for receipt let receipt = pending.get_receipt().await?; @@ -82,13 +85,13 @@ impl TransactionSubmitter { if let Some(gas_limit) = gas_limit { let used = receipt.gas_used; if used >= gas_limit { - return Err(anyhow!( + bail!( "{method} ran out of gas (used {used} of {gas_limit} limit). Tx: {tx_hash:?}" - )); + ); } } - return Err(anyhow!("{method} reverted on-chain. Tx hash: {tx_hash:?}")); + bail!("{method} reverted on-chain. Tx hash: {tx_hash:?}"); } Ok(tx_hash) diff --git a/crates/erc-8004-contract-clients/Cargo.toml b/crates/erc-8004-contract-clients/Cargo.toml new file mode 100644 index 0000000..ff5b58b --- /dev/null +++ b/crates/erc-8004-contract-clients/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "erc-8004-contract-clients" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } +alloy-provider = { version = "1.1", features = ["ws"] } +contract-clients-common = { path = "../contract-clients-common" } +futures-util = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_with = { version = "3.16", features = ["hex"] } +tokio = { version = "1.49", features = ["sync"] } +tracing = "0.1" diff --git a/crates/erc-8004-contract-clients/src/erc_8004_client.rs b/crates/erc-8004-contract-clients/src/erc_8004_client.rs new file mode 100644 index 0000000..c588fe2 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/erc_8004_client.rs @@ -0,0 +1,76 @@ +use crate::{ContractConfig, IdentityRegistryClient, ValidationRegistryClient}; +use alloy::{ + primitives::{Address, B256, U256}, + providers::DynProvider, +}; +use contract_clients_common::ProviderContext; + +/// High-level wrapper bundling ERC-8004 contract clients with a shared Alloy provider. +#[derive(Clone)] +pub struct Erc8004Client { + ctx: ProviderContext, + pub identity_registry: IdentityRegistryClient, + pub validation_registry: ValidationRegistryClient, +} + +impl Erc8004Client { + pub async fn new(config: ContractConfig, private_key: String) -> anyhow::Result { + let ctx = ProviderContext::new(&config.rpc_url, &private_key).await?; + Self::from_context(ctx, config).await + } + + /// Create a client from an existing [`ProviderContext`]. + /// + /// Use this when you want to share the same provider, wallet, and nonce + /// tracker across multiple clients (e.g. `BlacklightClient` and `Erc8004Client`). + pub async fn from_context( + ctx: ProviderContext, + config: ContractConfig, + ) -> anyhow::Result { + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); + + // Instantiate contract clients using the shared provider + let identity_registry = IdentityRegistryClient::new( + provider.clone(), + config.identity_registry_contract_address, + tx_lock.clone(), + ); + let validation_registry = ValidationRegistryClient::new( + provider.clone(), + config.validation_registry_contract_address, + tx_lock, + ); + + Ok(Self { + ctx, + identity_registry, + validation_registry, + }) + } + + /// Get the signer address + pub fn signer_address(&self) -> Address { + self.ctx.signer_address() + } + + /// Get the balance of the wallet + pub async fn get_balance(&self) -> anyhow::Result { + self.ctx.get_balance().await + } + + /// Get the balance of a specific address + pub async fn get_balance_of(&self, address: Address) -> anyhow::Result { + self.ctx.get_balance_of(address).await + } + + /// Send ETH to an address + pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { + self.ctx.send_eth(to, amount).await + } + + /// Get the current block number + pub async fn get_block_number(&self) -> anyhow::Result { + self.ctx.get_block_number().await + } +} diff --git a/crates/erc-8004-contract-clients/src/identity_registry.rs b/crates/erc-8004-contract-clients/src/identity_registry.rs new file mode 100644 index 0000000..68b0377 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/identity_registry.rs @@ -0,0 +1,122 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol! { + #[derive(Debug)] + struct MetadataEntry { + string metadataKey; + bytes metadataValue; + } + + #[sol(rpc)] + #[derive(Debug)] + contract IdentityRegistryUpgradeable { + function register() external returns (uint256); + function register(string calldata agentURI) external returns (uint256); + function register(string calldata agentURI, MetadataEntry[] calldata metadata) external returns (uint256); + function ownerOf(uint256 agentId) external view returns (address); + function tokenURI(uint256 agentId) external view returns (string memory); + function getAgentWallet(uint256 agentId) external view returns (address); + + // ERC-721 Transfer event emitted on registration (mint) + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + } +} + +use IdentityRegistryUpgradeable::{IdentityRegistryUpgradeableInstance, Transfer}; + +pub type IdentityMetadataEntry = MetadataEntry; + +/// Client for interacting with the IdentityRegistryUpgradeable contract. +#[derive(Clone)] +pub struct IdentityRegistryClient { + provider: P, + contract: IdentityRegistryUpgradeableInstance

, + submitter: + TransactionSubmitter, +} + +impl IdentityRegistryClient

{ + pub fn new(provider: P, address: Address, tx_lock: Arc>) -> Self { + let contract = IdentityRegistryUpgradeableInstance::new(address, provider.clone()); + let submitter = TransactionSubmitter::new(tx_lock); + Self { + provider, + contract, + submitter, + } + } + + /// Get the contract address. + pub fn address(&self) -> Address { + *self.contract.address() + } + + /// Register a new agent without a URI. + pub async fn register(&self) -> Result { + let call = self.contract.register_0(); + self.submitter.invoke("register", call).await + } + + /// Register a new agent with a URI. + pub async fn register_with_uri(&self, agent_uri: String) -> Result { + let call = self.contract.register_1(agent_uri); + self.submitter.invoke("register", call).await + } + + /// Register a new agent with a URI and metadata. + pub async fn register_with_metadata( + &self, + agent_uri: String, + metadata: Vec, + ) -> Result { + let call = self.contract.register_2(agent_uri, metadata); + self.submitter.invoke("register", call).await + } + + /// Register a new agent with a URI and return the agent ID. + /// Parses the Transfer event from the receipt to get the minted token ID. + pub async fn register_with_uri_and_get_id(&self, agent_uri: String) -> Result<(B256, U256)> { + let tx_hash = self.register_with_uri(agent_uri).await?; + let agent_id = self.get_agent_id_from_tx(tx_hash).await?; + Ok((tx_hash, agent_id)) + } + + /// Get the agent ID from a registration transaction by parsing the Transfer event. + async fn get_agent_id_from_tx(&self, tx_hash: B256) -> Result { + let receipt = self + .provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| anyhow::anyhow!("Transaction receipt not found"))?; + + // Parse Transfer events from the receipt logs + for log in receipt.inner.logs() { + if let Ok(transfer) = log.log_decode::() { + // For minting, `from` is the zero address + if transfer.inner.from == Address::ZERO { + return Ok(transfer.inner.tokenId); + } + } + } + + Err(anyhow::anyhow!( + "Transfer event not found in transaction receipt" + )) + } + + /// Rust stub for the Solidity `GetAgent` semantics: owner + URI + agent wallet. + pub async fn get_agent(&self, agent_id: U256) -> Result<(Address, String, Address)> { + let owner = self.contract.ownerOf(agent_id).call().await?; + let agent_uri = self.contract.tokenURI(agent_id).call().await?; + let agent_wallet = self.contract.getAgentWallet(agent_id).call().await?; + Ok((owner, agent_uri, agent_wallet)) + } +} diff --git a/crates/erc-8004-contract-clients/src/lib.rs b/crates/erc-8004-contract-clients/src/lib.rs new file mode 100644 index 0000000..98eb01d --- /dev/null +++ b/crates/erc-8004-contract-clients/src/lib.rs @@ -0,0 +1,124 @@ +use alloy::primitives::Address; + +pub mod erc_8004_client; +pub mod identity_registry; +pub mod validation_registry; + +// ============================================================================ +// Client Type Re-exports +// ============================================================================ + +pub use erc_8004_client::Erc8004Client; +pub use identity_registry::IdentityRegistryClient; +pub use validation_registry::ValidationRegistryClient; + +// ============================================================================ +// Type Aliases +// ============================================================================ + +/// Type alias for private key strings +pub type PrivateKey = String; + +// ============================================================================ +// Contract Configuration +// ============================================================================ + +/// Configuration for connecting to ERC-8004 smart contracts +/// +/// Contains addresses for the ERC-8004 registry contracts. +#[derive(Clone, Debug)] +pub struct ContractConfig { + pub identity_registry_contract_address: Address, + pub validation_registry_contract_address: Address, + pub rpc_url: String, +} + +impl Default for ContractConfig { + fn default() -> Self { + Self { + identity_registry_contract_address: Address::ZERO, + validation_registry_contract_address: Address::ZERO, + rpc_url: String::new(), + } + } +} + +impl ContractConfig { + /// Create a new configuration for deployed contracts + /// + /// # Arguments + /// * `rpc_url` - Ethereum RPC endpoint (HTTP or WebSocket) + /// * `identity_registry_contract_address` - Address of deployed IdentityRegistry contract + /// * `validation_registry_contract_address` - Address of deployed ValidationRegistry contract + pub fn new( + rpc_url: String, + identity_registry_contract_address: Address, + validation_registry_contract_address: Address, + ) -> Self { + Self { + identity_registry_contract_address, + validation_registry_contract_address, + rpc_url, + } + } + + /// Create a configuration with Anvil local testnet defaults + /// + /// Uses deterministic Anvil deployment addresses based on standard nonce order: + /// - IdentityRegistry deployed first (nonce 0) + /// - ValidationRegistry deployed second (nonce 1) + pub fn anvil_config() -> Self { + Self { + // Anvil deterministic addresses for deployer 0xf39F...2266 (account #0) + // These assume deployment order: IdentityRegistry -> ValidationRegistry + identity_registry_contract_address: "0x5FbDB2315678afecb367f032d93F642f64180aa3" + .parse::

() + .expect("Invalid token address"), + validation_registry_contract_address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" + .parse::
() + .expect("Invalid validation registry address"), + rpc_url: "http://127.0.0.1:8545".to_string(), + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_config_creation() { + let identity_registry_address = "0x89c1312Cedb0B0F67e4913D2076bd4a860652B69" + .parse::
() + .unwrap(); + let validation_registry_address = "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" + .parse::
() + .unwrap(); + + let config = ContractConfig::new( + "http://localhost:8545".to_string(), + identity_registry_address, + validation_registry_address, + ); + + assert_eq!( + config.identity_registry_contract_address, + identity_registry_address + ); + assert_eq!( + config.validation_registry_contract_address, + validation_registry_address + ); + assert_eq!(config.rpc_url, "http://localhost:8545"); + } + + #[test] + fn test_contract_address_parsing() { + let addr_str = "0x89c1312Cedb0B0F67e4913D2076bd4a860652B69"; + let addr = addr_str.parse::
(); + assert!(addr.is_ok(), "Contract address should parse correctly"); + } +} diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs new file mode 100644 index 0000000..e0d85b0 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -0,0 +1,123 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol! { + #[sol(rpc)] + #[derive(Debug)] + contract ValidationRegistryUpgradeable { + function validationRequest( + address validatorAddress, + uint256 agentId, + string calldata requestURI, + bytes32 requestHash, + uint64 snapshotId + ) external; + + function validationResponse( + bytes32 requestHash, + uint8 response, + string calldata responseURI, + bytes32 responseHash, + string calldata tag + ) external; + + event ValidationRequest( + address indexed validatorAddress, + uint256 indexed agentId, + string requestURI, + bytes32 indexed requestHash + ); + + event ValidationResponse( + address indexed validatorAddress, + uint256 indexed agentId, + bytes32 indexed requestHash, + uint8 response, + string responseURI, + bytes32 responseHash, + string tag + ); + } +} + +use ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; + +// Event type re-exports +pub type ValidationRequestEvent = ValidationRegistryUpgradeable::ValidationRequest; +pub type ValidationResponseEvent = ValidationRegistryUpgradeable::ValidationResponse; + +/// Client for interacting with the ValidationRegistryUpgradeable contract. +#[derive(Clone)] +pub struct ValidationRegistryClient { + contract: ValidationRegistryUpgradeableInstance

, + submitter: + TransactionSubmitter, +} + +impl ValidationRegistryClient

{ + pub fn new(provider: P, address: Address, tx_lock: Arc>) -> Self { + let contract = ValidationRegistryUpgradeableInstance::new(address, provider); + let submitter = TransactionSubmitter::new(tx_lock); + Self { + contract, + submitter, + } + } + + /// Get the contract address. + pub fn address(&self) -> Address { + *self.contract.address() + } + + /// Full validation request with snapshot ID (delegates to `validationRequest`). + pub async fn validation_request( + &self, + validator_address: Address, + agent_id: U256, + request_uri: String, + request_hash: B256, + snapshot_id: u64, + ) -> Result { + let call = self.contract.validationRequest( + validator_address, + agent_id, + request_uri, + request_hash, + snapshot_id, + ); + self.submitter.invoke("validationRequest", call).await + } + + /// Submit a validation response. + /// + /// # Arguments + /// * `request_hash` - The request hash identifying the validation request + /// * `response` - Response value 0-100 (0=invalid, 100=valid) + /// * `response_uri` - Optional URI pointing to response details + /// * `response_hash` - Hash of the response data (can be zero) + /// * `tag` - Tag identifying the response source (e.g., "heartbeat") + pub async fn validation_response( + &self, + request_hash: B256, + response: u8, + response_uri: String, + response_hash: B256, + tag: String, + ) -> Result { + let call = self.contract.validationResponse( + request_hash, + response, + response_uri, + response_hash, + tag, + ); + self.submitter.invoke("validationResponse", call).await + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 4c49bc8..73616b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ services: # Anvil - Local Ethereum testnet anvil: - image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-64cd680 + image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-dfb9847 container_name: blacklight-anvil ports: - "8545:8545" networks: - blacklight-network - command: ["--accounts", "15"] # Define the number of accounts to create + command: ["--accounts", "15"] # Define the number of accounts to create deployer + keeper + simulator x 2 + erc-8004-simulator + node x 10 = 15 healthcheck: test: [ "CMD-SHELL", "curl -sf -X POST -H 'Content-Type: application/json' --data '{\"jsonrpc\":\"2.0\",\"method\":\"eth_blockNumber\",\"params\":[],\"id\":1}' http://localhost:8545 || exit 1" ] interval: 5s @@ -15,6 +15,32 @@ services: retries: 20 start_period: 10s + + keeper: + build: + context: . + dockerfile: docker/Dockerfile + target: keeper + container_name: blacklight-keeper + depends_on: + anvil: + condition: service_healthy + networks: + - blacklight-network + restart: unless-stopped + environment: + - L2_RPC_URL=http://anvil:8545 + - L1_RPC_URL=http://anvil:8545 + - PRIVATE_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + - L2_STAKING_OPERATORS_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 + - L2_HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - L2_JAILING_POLICY_ADDRESS=0x0000000000000000000000000000000000000000 + - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0000000000000000000000000000000000000000 + # ERC-8004 Keeper configuration + - ENABLE_ERC8004_KEEPER=true + - L2_VALIDATION_REGISTRY_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + - RUST_LOG=info + # NilCC Simulator - Submits HTXs to the contract simulator: build: @@ -32,7 +58,7 @@ services: - HTXS_PATH=/app/data/htxs.json - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 + - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 - RUST_LOG=info networks: - blacklight-network @@ -50,12 +76,34 @@ services: condition: service_healthy environment: - RPC_URL=http://anvil:8545 - - PRIVATE_KEY=0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6 + - PRIVATE_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a - PUBLIC_KEY=0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 - HTXS_PATH=/app/data/htxs.json - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 + - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - RUST_LOG=info + networks: + - blacklight-network + restart: unless-stopped + + # ERC-8004 Simulator - Registers agents and submits validation requests + erc-8004-simulator: + build: + context: . + dockerfile: docker/Dockerfile + target: erc_8004_simulator + container_name: erc-8004-simulator + depends_on: + anvil: + condition: service_healthy + environment: + - RPC_URL=http://anvil:8545 + - PRIVATE_KEY=0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a + - IDENTITY_REGISTRY_CONTRACT_ADDRESS=0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1 + - VALIDATION_REGISTRY_CONTRACT_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + - VALIDATOR_ADDRESS=0x90F79bf6EB2c4f870365E785982E1f101E93b906 + - AGENT_URI=https://api.nilai.nillion.network/v1/health/ - RUST_LOG=info networks: - blacklight-network @@ -82,12 +130,13 @@ services: node-init: condition: service_completed_successfully deploy: - replicas: 10 # IMPORTANT: Define the number of accounts to create in the anvil container as 1 for the deployer, 2 for the simulators, X for the nodes + replicas: 10 # IMPORTANT: Define the number of accounts to create in the anvil container as 1 for the deployer, 1 for the keeper,3 for the simulators, X for the nodes environment: + - RUST_LOG=debug - RPC_URL=http://anvil:8545 - TOKEN_CONTRACT_ADDRESS=0x5FbDB2315678afecb367f032d93F642f64180aa3 - STAKING_CONTRACT_ADDRESS=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - - MANAGER_CONTRACT_ADDRESS=0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9 + - MANAGER_CONTRACT_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 # Provide a single mnemonic and scale this service (see docker/README.md) - MNEMONIC=test test test test test test test test test test test junk # Shift indices if you want to skip accounts (e.g., keep deployer/simulators on low indices) diff --git a/docker/Dockerfile b/docker/Dockerfile index 98f8b89..2f23cd1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage Dockerfile for blacklight project with cross-compilation support -# Builds all binaries: blacklight_node, nilcc_simulator, monitor, keeper +# Builds all binaries: blacklight_node, simulator (nilcc/erc8004), monitor, keeper FROM --platform=$BUILDPLATFORM rust:1.91-slim-trixie AS builder @@ -57,7 +57,7 @@ RUN mkdir -p ~/.cargo && \ # Copy project files COPY Cargo.toml ./ COPY crates ./crates -COPY nilcc-simulator ./nilcc-simulator +COPY simulator ./simulator COPY keeper ./keeper COPY blacklight-node ./blacklight-node COPY monitor ./monitor @@ -84,7 +84,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo build --release --target $RUST_TARGET && \ mkdir -p /out/bin && \ cp target/$RUST_TARGET/release/blacklight-node /out/bin/ && \ - cp target/$RUST_TARGET/release/nilcc-simulator /out/bin/ && \ + cp target/$RUST_TARGET/release/simulator /out/bin/ && \ cp target/$RUST_TARGET/release/monitor /out/bin/ && \ cp target/$RUST_TARGET/release/keeper /out/bin/ @@ -112,12 +112,17 @@ COPY --from=foundry /usr/local/bin/cast /usr/local/bin/cast COPY --chmod=755 docker/entrypoints/derive_private_key.sh /usr/local/bin/derive_private_key.sh ENTRYPOINT ["/usr/local/bin/derive_private_key.sh", "/usr/local/bin/blacklight-node"] -# Runtime stage for nilcc_simulator +# Runtime stage for nilcc_simulator (HTX submission) FROM base_release AS nilcc_simulator -COPY --from=builder /out/bin/nilcc-simulator /usr/local/bin/nilcc-simulator +COPY --from=builder /out/bin/simulator /usr/local/bin/simulator COPY --from=builder /app/data /app/data ENV HTXS_PATH=/app/data/htxs.json -ENTRYPOINT ["/usr/local/bin/nilcc-simulator"] +ENTRYPOINT ["/usr/local/bin/simulator", "nilcc"] + +# Runtime stage for erc_8004_simulator (ERC-8004 validation requests) +FROM base_release AS erc_8004_simulator +COPY --from=builder /out/bin/simulator /usr/local/bin/simulator +ENTRYPOINT ["/usr/local/bin/simulator", "erc8004"] # Runtime stage for monitor FROM base_release AS monitor diff --git a/keeper/Cargo.toml b/keeper/Cargo.toml index b68d42e..21ee092 100644 --- a/keeper/Cargo.toml +++ b/keeper/Cargo.toml @@ -16,3 +16,5 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } blacklight-contract-clients = { path = "../crates/blacklight-contract-clients" } +contract-clients-common = { path = "../crates/contract-clients-common" } +erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } diff --git a/keeper/src/args.rs b/keeper/src/args.rs index 9aec607..5e65540 100644 --- a/keeper/src/args.rs +++ b/keeper/src/args.rs @@ -34,6 +34,14 @@ pub struct CliArgs { #[arg(long, env = "DISABLE_JAILING")] pub disable_jailing: bool, + /// L2 ValidationRegistry contract address for ERC-8004 validation responses + #[arg(long, env = "L2_VALIDATION_REGISTRY_ADDRESS")] + pub l2_validation_registry_address: Option

, + + /// Enable ERC-8004 keeper functionality + #[arg(long, env = "ENABLE_ERC8004_KEEPER", default_value_t = false)] + pub enable_erc8004: bool, + /// L1 EmissionsController contract address #[arg(long, env = "L1_EMISSIONS_CONTROLLER_ADDRESS")] pub l1_emissions_controller_address: Address, @@ -82,6 +90,7 @@ pub struct KeeperConfig { pub l1_rpc_url: String, pub l2_heartbeat_manager_address: Address, pub l2_jailing_policy_address: Option
, + pub l2_validation_registry_address: Option
, pub l1_emissions_controller_address: Address, pub l2_staking_operators_address: Address, pub private_key: String, @@ -90,6 +99,7 @@ pub struct KeeperConfig { pub tick_interval: Duration, pub emissions_interval: Duration, pub disable_jailing: bool, + pub enable_erc8004: bool, pub otel: Option, } @@ -104,12 +114,18 @@ impl KeeperConfig { let l2_staking_operators_address = args.l2_staking_operators_address; let l2_jailing_policy_address = args.l2_jailing_policy_address; let disable_jailing = args.disable_jailing; + let enable_erc8004 = args.enable_erc8004; let private_key = args.private_key; let l2_jailing_policy_address = if disable_jailing { None } else { l2_jailing_policy_address }; + let l2_validation_registry_address = if enable_erc8004 { + args.l2_validation_registry_address + } else { + None + }; let l1_bridge_value = args.l1_bridge_value_wei; let lookback_blocks = args.lookback_blocks; let tick_interval = Duration::from_secs(args.tick_interval_secs); @@ -139,6 +155,7 @@ impl KeeperConfig { l1_rpc_url, l2_heartbeat_manager_address, l2_jailing_policy_address, + l2_validation_registry_address, l1_emissions_controller_address, l2_staking_operators_address, private_key, @@ -147,6 +164,7 @@ impl KeeperConfig { tick_interval, emissions_interval, disable_jailing, + enable_erc8004, otel, }) } diff --git a/keeper/src/clients.rs b/keeper/src/clients.rs index 2c4db89..b948832 100644 --- a/keeper/src/clients.rs +++ b/keeper/src/clients.rs @@ -1,11 +1,14 @@ use crate::contracts::{EmissionsController, Erc20, JailingPolicy, RewardPolicy}; use alloy::{ - network::{Ethereum, EthereumWallet, NetworkWallet}, primitives::{Address, U256}, - providers::{DynProvider, Provider, ProviderBuilder, WsConnect}, - signers::local::PrivateKeySigner, + providers::DynProvider, }; use blacklight_contract_clients::{HeartbeatManager, StakingOperators}; +use contract_clients_common::ProviderContext; +use erc_8004_contract_clients::ValidationRegistryClient; +use erc_8004_contract_clients::validation_registry::ValidationRegistryUpgradeable; +use std::sync::Arc; +use tokio::sync::Mutex; pub type HeartbeatManagerInstance = HeartbeatManager::HeartbeatManagerInstance; pub type StakingOperatorsInstance = StakingOperators::StakingOperatorsInstance; @@ -14,36 +17,16 @@ pub type EmissionsControllerInstance = EmissionsController::EmissionsControllerInstance; pub type RewardPolicyInstance = RewardPolicy::RewardPolicyInstance; pub type ERC20Instance = Erc20::Erc20Instance; - -async fn connect_ws( - rpc_url: &str, - private_key: &str, -) -> anyhow::Result<(DynProvider, EthereumWallet)> { - let ws_url = rpc_url - .replace("http://", "ws://") - .replace("https://", "wss://"); - let ws = WsConnect::new(ws_url).with_max_retries(u32::MAX); - let signer: PrivateKeySigner = private_key.parse::()?; - let wallet = EthereumWallet::from(signer); - - let provider: DynProvider = ProviderBuilder::new() - .wallet(wallet.clone()) - .with_simple_nonce_management() - .with_gas_estimation() - .connect_ws(ws) - .await? - .erased(); - - Ok((provider, wallet)) -} +pub type ValidationRegistryInstance = + ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; /// WebSocket-based client for L2 keeper duties (heartbeat rounds + jailing) pub struct L2KeeperClient { + ctx: ProviderContext, heartbeat_manager: HeartbeatManagerInstance, staking_operators: StakingOperatorsInstance, jailing_policy: Option, - provider: DynProvider, - wallet: EthereumWallet, + validation_registry: Option>, } impl L2KeeperClient { @@ -52,22 +35,28 @@ impl L2KeeperClient { heartbeat_manager_address: Address, staking_operators_address: Address, jailing_policy_address: Option
, + validation_registry_address: Option
, private_key: String, ) -> anyhow::Result { - let (provider, wallet) = connect_ws(&rpc_url, &private_key).await?; + let ctx = ProviderContext::with_ws_retries(&rpc_url, &private_key, Some(u32::MAX)).await?; + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); + let heartbeat_manager = HeartbeatManagerInstance::new(heartbeat_manager_address, provider.clone()); let staking_operators = StakingOperatorsInstance::new(staking_operators_address, provider.clone()); let jailing_policy = jailing_policy_address.map(|addr| JailingPolicyInstance::new(addr, provider.clone())); + let validation_registry = validation_registry_address + .map(|addr| ValidationRegistryClient::new(provider.clone(), addr, tx_lock)); Ok(Self { + ctx, heartbeat_manager, staking_operators, jailing_policy, - provider, - wallet, + validation_registry, }) } @@ -83,32 +72,40 @@ impl L2KeeperClient { self.jailing_policy.as_ref() } + pub fn validation_registry(&self) -> Option<&ValidationRegistryClient> { + self.validation_registry.as_ref() + } + pub fn reward_policy(&self, address: Address) -> RewardPolicyInstance { - RewardPolicyInstance::new(address, self.provider.clone()) + RewardPolicyInstance::new(address, self.ctx.provider().clone()) } pub fn erc20(&self, address: Address) -> ERC20Instance { - ERC20Instance::new(address, self.provider.clone()) + ERC20Instance::new(address, self.ctx.provider().clone()) } pub fn provider(&self) -> DynProvider { - self.provider.clone() + self.ctx.provider().clone() } pub fn signer_address(&self) -> Address { - >::default_signer_address(&self.wallet) + self.ctx.signer_address() + } + + /// Shared transaction lock for nonce coordination across all contract clients. + pub fn tx_lock(&self) -> Arc> { + self.ctx.tx_lock() } pub async fn get_balance(&self) -> anyhow::Result { - Ok(self.provider.get_balance(self.signer_address()).await?) + self.ctx.get_balance().await } } /// WebSocket-based client for L1 emissions minting/bridging pub struct L1EmissionsClient { + ctx: ProviderContext, emissions: EmissionsControllerInstance, - provider: DynProvider, - wallet: EthereumWallet, } impl L1EmissionsClient { @@ -117,13 +114,9 @@ impl L1EmissionsClient { emissions_address: Address, private_key: String, ) -> anyhow::Result { - let (provider, wallet) = connect_ws(&rpc_url, &private_key).await?; - let emissions = EmissionsControllerInstance::new(emissions_address, provider.clone()); - Ok(Self { - emissions, - provider, - wallet, - }) + let ctx = ProviderContext::new(&rpc_url, &private_key).await?; + let emissions = EmissionsControllerInstance::new(emissions_address, ctx.provider().clone()); + Ok(Self { ctx, emissions }) } pub fn emissions(&self) -> &EmissionsControllerInstance { @@ -131,14 +124,14 @@ impl L1EmissionsClient { } pub fn provider(&self) -> DynProvider { - self.provider.clone() + self.ctx.provider().clone() } pub fn signer_address(&self) -> Address { - >::default_signer_address(&self.wallet) + self.ctx.signer_address() } pub async fn get_balance(&self) -> anyhow::Result { - Ok(self.provider.get_balance(self.signer_address()).await?) + self.ctx.get_balance().await } } diff --git a/keeper/src/erc8004/events.rs b/keeper/src/erc8004/events.rs new file mode 100644 index 0000000..f5ad1eb --- /dev/null +++ b/keeper/src/erc8004/events.rs @@ -0,0 +1,277 @@ +use crate::{erc8004::ValidationRequestInfo, l2::KeeperState, metrics}; +use alloy::{ + primitives::B256, + providers::Provider, + rpc::types::Log, + sol_types::{SolEvent, SolValue}, +}; +use anyhow::Context; +use erc_8004_contract_clients::validation_registry::{ + ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance, ValidationRequestEvent, +}; +use futures_util::{Stream, StreamExt}; +use std::{pin::pin, sync::Arc}; +use tokio::sync::Mutex; +use tracing::{debug, error, info, warn}; + +use super::Erc8004State; + +pub type ValidationRegistryInstance

= ValidationRegistryUpgradeableInstance

; + +/// Event listener for ERC-8004 ValidationRequest events. +pub struct Erc8004EventListener { + registry: ValidationRegistryInstance

, +} + +impl Erc8004EventListener

{ + pub fn new(registry: ValidationRegistryInstance

) -> Self { + Self { registry } + } + + /// Process historical ValidationRequest events and populate state. + pub async fn process_historical_events( + &self, + from_block: u64, + to_block: u64, + state: &mut Erc8004State, + ) -> anyhow::Result<()> { + let events = self + .query_events::(from_block, to_block) + .await?; + + for (event, _log) in events { + let heartbeat_key = compute_heartbeat_key( + event.validatorAddress, + event.agentId, + &event.requestURI, + event.requestHash, + ); + let info = ValidationRequestInfo::new( + event.validatorAddress, + event.agentId, + event.requestURI, + event.requestHash, + ); + state.pending_validations.insert(heartbeat_key, info); + } + + info!( + from_block, + to_block, + pending_validations = state.pending_validations.len(), + "Loaded historical ERC-8004 validation requests" + ); + + Ok(()) + } + + /// Spawn background task to listen for new ValidationRequest events. + pub async fn spawn( + self, + from_block: u64, + state: Arc>, + ) -> anyhow::Result<()> { + let validation_request = self.subscribe::(from_block).await?; + tokio::spawn(Self::process_validation_requests(validation_request, state)); + Ok(()) + } + + async fn query_events( + &self, + from_block: u64, + to_block: u64, + ) -> anyhow::Result> { + let events = self + .registry + .event_filter::() + .from_block(from_block) + .to_block(to_block) + .query() + .await?; + Ok(events) + } + + async fn subscribe( + &self, + from_block: u64, + ) -> anyhow::Result + 'static> { + let event_name = E::SIGNATURE + .split_once('(') + .map(|(name, _)| name) + .unwrap_or(E::SIGNATURE); + let stream = self + .registry + .event_filter::() + .from_block(from_block) + .subscribe() + .await + .context("Failed to subscribe to ERC-8004 events")? + .into_stream() + .filter_map(async move |e| match e { + Ok((event, _)) => { + metrics::get().l2.events.inc_events_received(event_name); + Some(event) + } + Err(e) => { + error!("Failed to receive {} event: {e}", E::SIGNATURE); + None + } + }); + Ok(stream) + } + + async fn process_validation_requests( + events: impl Stream, + state: Arc>, + ) { + let mut events = pin!(events); + while let Some(event) = events.next().await { + let heartbeat_key = compute_heartbeat_key( + event.validatorAddress, + event.agentId, + &event.requestURI, + event.requestHash, + ); + + let info = ValidationRequestInfo::new( + event.validatorAddress, + event.agentId, + event.requestURI.clone(), + event.requestHash, + ); + + let mut guard = state.lock().await; + guard + .erc8004 + .pending_validations + .insert(heartbeat_key, info); + metrics::get() + .l2 + .erc8004 + .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); + + info!( + heartbeat_key = ?heartbeat_key, + validator = ?event.validatorAddress, + agent_id = ?event.agentId, + request_hash = ?event.requestHash, + "ERC-8004 validation request tracked" + ); + } + } +} + +/// Compute the heartbeat key from validation request parameters. +/// +/// This matches the Solidity encoding: `keccak256(abi.encode(validatorAddress, agentId, requestURI, requestHash))` +pub fn compute_heartbeat_key( + validator_address: alloy::primitives::Address, + agent_id: alloy::primitives::U256, + request_uri: &str, + request_hash: B256, +) -> B256 { + let tuple = ( + validator_address, + agent_id, + request_uri.to_string(), + request_hash, + ); + let encoded = tuple.abi_encode(); + alloy::primitives::keccak256(&encoded) +} + +/// Update ERC-8004 state when a round finalizes. +/// +/// Called from the L2 event processor when RoundFinalized events are received. +pub fn on_round_finalized(state: &mut Erc8004State, heartbeat_key: B256, outcome: u8) { + if let Some(info) = state.pending_validations.get_mut(&heartbeat_key) { + let response = match outcome { + 1 => 100, // valid + 2 => 0, // invalid + 3 => 50, // inconclusive + other => { + warn!( + heartbeat_key = %heartbeat_key, + outcome = other, + "Unexpected HeartbeatManager outcome; defaulting ERC-8004 response to 0" + ); + 0 + } + }; + info.outcome = Some(response); + info!( + heartbeat_key = %heartbeat_key, + outcome, + response, + request_hash = %info.request_hash, + "ERC-8004 validation round finalized" + ); + } else { + debug!( + heartbeat_key = %heartbeat_key, + "RoundFinalized for unknown heartbeat_key (not an ERC-8004 validation)" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{Address, U256}; + + #[test] + fn test_heartbeat_key_computation_matches_htx() { + // Test data from htx.rs test: + // abi.encode(0x5fc8d32690cc91d4c39d9d3abcbd16989f875707, 0, "https://api.nilai.nillion.network/", 0xa6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac) + let validator_address: Address = "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707" + .parse() + .unwrap(); + let agent_id = U256::ZERO; + let request_uri = "https://api.nilai.nillion.network/"; + let request_hash: B256 = + "0xa6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac" + .parse() + .unwrap(); + + let heartbeat_key = + compute_heartbeat_key(validator_address, agent_id, request_uri, request_hash); + + // The heartbeat key should be the keccak256 of the ABI-encoded tuple + // This should match what the Solidity contract computes + assert!(!heartbeat_key.is_zero()); + + // Verify consistency - same inputs should produce same output + let heartbeat_key2 = + compute_heartbeat_key(validator_address, agent_id, request_uri, request_hash); + assert_eq!(heartbeat_key, heartbeat_key2); + } + + #[test] + fn test_heartbeat_key_different_for_different_inputs() { + let validator_address: Address = "0x5fc8d32690cc91d4c39d9d3abcbd16989f875707" + .parse() + .unwrap(); + let agent_id = U256::ZERO; + let request_uri = "https://api.nilai.nillion.network/"; + let request_hash: B256 = + "0xa6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac" + .parse() + .unwrap(); + + let key1 = compute_heartbeat_key(validator_address, agent_id, request_uri, request_hash); + + // Different agent_id should produce different key + let key2 = + compute_heartbeat_key(validator_address, U256::from(1), request_uri, request_hash); + assert_ne!(key1, key2); + + // Different request_uri should produce different key + let key3 = compute_heartbeat_key( + validator_address, + agent_id, + "https://different.uri/", + request_hash, + ); + assert_ne!(key1, key3); + } +} diff --git a/keeper/src/erc8004/mod.rs b/keeper/src/erc8004/mod.rs new file mode 100644 index 0000000..de84fdd --- /dev/null +++ b/keeper/src/erc8004/mod.rs @@ -0,0 +1,51 @@ +use alloy::primitives::{Address, B256, U256}; +use std::collections::HashMap; + +pub mod events; +pub mod responder; + +/// State tracking for ERC-8004 validations. +/// +/// Tracks validation requests by their heartbeat key and stores the outcome +/// when rounds finalize, enabling the keeper to submit validation responses. +#[derive(Default)] +pub struct Erc8004State { + /// Maps heartbeat_key -> ValidationRequestInfo + pub pending_validations: HashMap, +} + +/// Information about a pending validation request. +#[derive(Debug, Clone)] +#[allow(dead_code)] // Fields kept for debugging and potential future use +pub struct ValidationRequestInfo { + /// The validator address that submitted the request + pub validator_address: Address, + /// The agent ID being validated + pub agent_id: U256, + /// The request URI + pub request_uri: String, + /// The request hash (unique identifier for the validation) + pub request_hash: B256, + /// The ERC-8004 validation response value (0-100), mapped from HeartbeatManager outcome. + pub outcome: Option, + /// Whether the validation response has been submitted + pub response_submitted: bool, +} + +impl ValidationRequestInfo { + pub fn new( + validator_address: Address, + agent_id: U256, + request_uri: String, + request_hash: B256, + ) -> Self { + Self { + validator_address, + agent_id, + request_uri, + request_hash, + outcome: None, + response_submitted: false, + } + } +} diff --git a/keeper/src/erc8004/responder.rs b/keeper/src/erc8004/responder.rs new file mode 100644 index 0000000..03dbd56 --- /dev/null +++ b/keeper/src/erc8004/responder.rs @@ -0,0 +1,100 @@ +use crate::{l2::KeeperState, metrics}; +use alloy::hex; +use alloy::primitives::B256; +use alloy::providers::Provider; +use erc_8004_contract_clients::ValidationRegistryClient; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, error, info}; + +/// Submits validation responses for finalized ERC-8004 validation rounds. +pub struct ValidationResponder { + registry: ValidationRegistryClient

, + state: Arc>, +} + +impl ValidationResponder

{ + pub fn new(registry: ValidationRegistryClient

, state: Arc>) -> Self { + Self { registry, state } + } + + /// Process pending validation responses. + /// + /// Called from the tick loop to submit validation responses for any + /// validations that have received an outcome but haven't been responded to yet. + pub async fn process_responses(&self) -> anyhow::Result<()> { + // Collect jobs to process outside the lock + let jobs: Vec<_> = { + let state = self.state.lock().await; + state + .erc8004 + .pending_validations + .iter() + .filter(|(_, info)| info.outcome.is_some() && !info.response_submitted) + .map(|(key, info)| (*key, info.request_hash, info.outcome.unwrap())) + .collect() + }; + + if !jobs.is_empty() { + info!( + ready_count = jobs.len(), + "ERC-8004 validations ready for response submission" + ); + } + + if jobs.is_empty() { + debug!( + "No ERC-8004 validations ready for response (waiting for outcomes or already submitted)" + ); + return Ok(()); + } + + for (heartbeat_key, request_hash, outcome) in jobs { + info!( + request_hash = %hex::encode(request_hash), + outcome, + "Submitting ERC-8004 validation response" + ); + match self.submit_response(request_hash, outcome).await { + Ok(tx_hash) => { + // Mark as submitted + let mut state = self.state.lock().await; + if let Some(info) = state.erc8004.pending_validations.get_mut(&heartbeat_key) { + info.response_submitted = true; + } + metrics::get().l2.erc8004.inc_responses_submitted(); + info!( + request_hash = ?request_hash, + outcome, + tx_hash = ?tx_hash, + "Submitted ERC-8004 validation response" + ); + } + Err(e) => { + error!( + request_hash = ?request_hash, + outcome, + "Failed to submit ERC-8004 validation response: {e}" + ); + } + } + } + + Ok(()) + } + + async fn submit_response(&self, request_hash: B256, outcome: u8) -> anyhow::Result { + let tx_hash = self + .registry + .validation_response( + request_hash, + outcome, + String::new(), // responseURI - empty + B256::ZERO, // responseHash - zero + "heartbeat".to_string(), // tag + ) + .await?; + + Ok(tx_hash) + } +} diff --git a/keeper/src/l2/escalator.rs b/keeper/src/l2/escalator.rs index fda3044..1f44fb9 100644 --- a/keeper/src/l2/escalator.rs +++ b/keeper/src/l2/escalator.rs @@ -1,12 +1,12 @@ use crate::{clients::L2KeeperClient, l2::KeeperState, metrics}; use alloy::primitives::{B256, Bytes}; -use blacklight_contract_clients::{ - common::{errors::decode_any_error, tx_submitter::TransactionSubmitter}, - heartbeat_manager::HeartbeatManagerErrors, -}; +use blacklight_contract_clients::heartbeat_manager::HeartbeatManagerErrors; use std::{collections::HashMap, sync::Arc}; use tokio::sync::Mutex; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; + +use contract_clients_common::errors::decode_any_error; +use contract_clients_common::tx_submitter::TransactionSubmitter; pub(crate) struct RoundEscalator { client: Arc, @@ -16,10 +16,11 @@ pub(crate) struct RoundEscalator { impl RoundEscalator { pub(crate) fn new(client: Arc, state: Arc>) -> Self { + let submitter = TransactionSubmitter::new(client.tx_lock()); Self { client, state, - submitter: TransactionSubmitter::new(Default::default()), + submitter, } } @@ -72,6 +73,14 @@ impl RoundEscalator { for (heartbeat_key, round, deadline, raw_htx) in candidates { if block_timestamp <= deadline { + debug!( + heartbeat_key = ?heartbeat_key, + round, + deadline, + block_timestamp, + remaining_secs = deadline - block_timestamp, + "Round not yet past deadline, skipping" + ); continue; } diff --git a/keeper/src/l2/events.rs b/keeper/src/l2/events.rs index 75e74ff..3a29f8c 100644 --- a/keeper/src/l2/events.rs +++ b/keeper/src/l2/events.rs @@ -1,5 +1,6 @@ use crate::{ clients::HeartbeatManagerInstance, + erc8004::{ValidationRequestInfo, events::on_round_finalized}, l2::{KeeperState, RoundKey}, metrics, }; @@ -11,6 +12,7 @@ use blacklight_contract_clients::{ HeartbeatEnqueuedEvent, RewardDistributionAbandonedEvent, RewardsDistributedEvent, RoundFinalizedEvent, RoundStartedEvent, SlashingCallbackFailedEvent, }, + htx::Erc8004Htx, }; use futures_util::{Stream, StreamExt}; use std::{pin::pin, sync::Arc}; @@ -73,6 +75,7 @@ impl EventListener { }; let entry = state.rounds.entry(key).or_default(); entry.outcome = Some(event.outcome); + on_round_finalized(&mut state.erc8004, event.heartbeatKey, event.outcome); } for (event, _log) in rewards_done { let key = RoundKey { @@ -220,6 +223,39 @@ impl EventListener { members = entry.members.len(), "Round started" ); + + // Detect ERC-8004 HTXs and track them with HeartbeatManager's heartbeat_key + if let Ok(erc8004_htx) = Erc8004Htx::try_decode(&event.rawHTX) { + // Only add if not already tracked (first round) + if event.round == 1 + && !guard + .erc8004 + .pending_validations + .contains_key(&event.heartbeatKey) + { + let info = ValidationRequestInfo::new( + erc8004_htx.validator_address, + erc8004_htx.agent_id, + erc8004_htx.request_uri.clone(), + erc8004_htx.request_hash, + ); + guard + .erc8004 + .pending_validations + .insert(event.heartbeatKey, info); + metrics::get() + .l2 + .erc8004 + .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); + info!( + heartbeat_key = %event.heartbeatKey, + validator = %erc8004_htx.validator_address, + agent_id = %erc8004_htx.agent_id, + request_hash = %erc8004_htx.request_hash, + "ERC-8004 validation tracked from RoundStarted" + ); + } + } } } @@ -242,6 +278,9 @@ impl EventListener { outcome = event.outcome, "Round finalized" ); + + // Notify ERC-8004 state about the round finalization + on_round_finalized(&mut guard.erc8004, event.heartbeatKey, event.outcome); } } diff --git a/keeper/src/l2/jailing.rs b/keeper/src/l2/jailing.rs index a26742d..42f17df 100644 --- a/keeper/src/l2/jailing.rs +++ b/keeper/src/l2/jailing.rs @@ -4,7 +4,7 @@ use crate::{ }; use alloy::primitives::Address; use anyhow::bail; -use blacklight_contract_clients::common::errors::decode_any_error; +use contract_clients_common::errors::decode_any_error; use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; diff --git a/keeper/src/l2/mod.rs b/keeper/src/l2/mod.rs index 6e81ce7..24a46b3 100644 --- a/keeper/src/l2/mod.rs +++ b/keeper/src/l2/mod.rs @@ -1,3 +1,4 @@ +use crate::erc8004::Erc8004State; use alloy::primitives::{Address, B256, Bytes}; use std::collections::HashMap; @@ -29,4 +30,5 @@ struct RoundState { pub struct KeeperState { raw_htx_by_heartbeat: HashMap, rounds: HashMap, + pub erc8004: Erc8004State, } diff --git a/keeper/src/l2/rewards.rs b/keeper/src/l2/rewards.rs index f4b8a25..fb7b89d 100644 --- a/keeper/src/l2/rewards.rs +++ b/keeper/src/l2/rewards.rs @@ -6,10 +6,12 @@ use crate::{ use alloy::primitives::{Address, U256, map::HashMap, utils::format_units}; use anyhow::{Context, anyhow, bail}; use blacklight_contract_clients::{ - ProtocolConfig::ProtocolConfigInstance, - common::{errors::decode_any_error, tx_submitter::TransactionSubmitter}, - heartbeat_manager::HeartbeatManagerErrors, + ProtocolConfig::ProtocolConfigInstance, heartbeat_manager::HeartbeatManagerErrors, }; + +use contract_clients_common::errors::decode_any_error; +use contract_clients_common::tx_submitter::TransactionSubmitter; + use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, info, instrument, warn}; @@ -44,11 +46,12 @@ pub(crate) struct RewardsDistributor { impl RewardsDistributor { pub(crate) fn new(client: Arc, state: Arc>) -> Self { + let submitter = TransactionSubmitter::new(client.tx_lock()).with_gas_buffer(); Self { client, state, rewards_context: Default::default(), - submitter: TransactionSubmitter::new(Default::default()).with_gas_buffer(), + submitter, } } diff --git a/keeper/src/l2/supervisor.rs b/keeper/src/l2/supervisor.rs index 1b6930a..c9cfd73 100644 --- a/keeper/src/l2/supervisor.rs +++ b/keeper/src/l2/supervisor.rs @@ -1,6 +1,7 @@ use crate::{ args::KeeperConfig, - clients::L2KeeperClient, + clients::{L2KeeperClient, ValidationRegistryInstance}, + erc8004::{events::Erc8004EventListener, responder::ValidationResponder}, l2::{ KeeperState, escalator::RoundEscalator, events::EventListener, jailing::Jailer, rewards::RewardsDistributor, @@ -11,7 +12,7 @@ use alloy::{eips::BlockId, providers::Provider}; use anyhow::Context; use std::sync::Arc; use tokio::{sync::Mutex, time::interval}; -use tracing::error; +use tracing::{debug, error, info}; pub struct L2Supervisor { client: Arc, @@ -19,22 +20,27 @@ pub struct L2Supervisor { jailer: Jailer, rewards_distributor: RewardsDistributor, round_escalator: RoundEscalator, + erc8004_enabled: bool, } impl L2Supervisor { pub async fn new( client: Arc, state: Arc>, + config: &KeeperConfig, ) -> anyhow::Result { let jailer = Jailer::new(client.clone(), state.clone()); let rewards_distributor = RewardsDistributor::new(client.clone(), state.clone()); let round_escalator = RoundEscalator::new(client.clone(), state.clone()); + let erc8004_enabled = + config.enable_erc8004 && config.l2_validation_registry_address.is_some(); Ok(Self { client, state, jailer, rewards_distributor, round_escalator, + erc8004_enabled, }) } @@ -47,6 +53,23 @@ impl L2Supervisor { .context("Failed to find latest block")?; let from_block = latest_block.saturating_sub(config.lookback_blocks); + // Process historic ERC-8004 requests first so round outcomes can be reconciled. + if self.erc8004_enabled + && let Some(registry) = self.client.validation_registry() + { + let raw_registry = + ValidationRegistryInstance::new(registry.address(), self.client.provider()); + let erc8004_listener = Erc8004EventListener::new(raw_registry); + erc8004_listener + .process_historical_events( + from_block, + latest_block, + &mut self.state.lock().await.erc8004, + ) + .await + .context("Failed to process historical ERC-8004 events")?; + } + // Process historic events from current block - lookback until now let event_listener = EventListener::new(self.client.heartbeat_manager().clone()); event_listener @@ -58,13 +81,42 @@ impl L2Supervisor { event_listener .spawn(latest_block.saturating_add(1), self.state.clone()) .await - .context("Failed tp spawn event listener")?; + .context("Failed to spawn event listener")?; + + // Spawn ERC-8004 event listener if enabled + if self.erc8004_enabled + && let Some(registry) = self.client.validation_registry() + { + let raw_registry = + ValidationRegistryInstance::new(registry.address(), self.client.provider()); + let erc8004_listener = Erc8004EventListener::new(raw_registry); + erc8004_listener + .spawn(latest_block.saturating_add(1), self.state.clone()) + .await + .context("Failed to spawn ERC-8004 event listener")?; + + info!("ERC-8004 event listener spawned"); + } tokio::spawn(self.run(config)); Ok(()) } async fn run(mut self, config: KeeperConfig) { + // Create ERC-8004 responder if enabled (uses shared tx_lock via ValidationRegistryClient) + let erc8004_responder = if self.erc8004_enabled { + self.client + .validation_registry() + .map(|client| ValidationResponder::new(client.clone(), self.state.clone())) + } else { + None + }; + info!( + erc8004_responder_created = erc8004_responder.is_some(), + tick_interval_ms = config.tick_interval.as_millis() as u64, + "L2 supervisor run loop starting" + ); + let mut ticker = interval(config.tick_interval); loop { ticker.tick().await; @@ -98,6 +150,13 @@ impl L2Supervisor { self.process_rounds(block_timestamp).await; + if let Some(ref responder) = erc8004_responder { + debug!("Tick: processing ERC-8004 validation responses"); + if let Err(e) = responder.process_responses().await { + error!("Failed to process ERC-8004 validation responses: {e}"); + } + } + match self .client .provider() diff --git a/keeper/src/main.rs b/keeper/src/main.rs index c9fa6b1..7b24ab0 100644 --- a/keeper/src/main.rs +++ b/keeper/src/main.rs @@ -23,6 +23,7 @@ use crate::l2::L2Supervisor; mod args; mod clients; mod contracts; +mod erc8004; mod l1; mod l2; mod metrics; @@ -115,6 +116,16 @@ async fn main() -> Result<()> { ); } + if config.enable_erc8004 { + if let Some(addr) = config.l2_validation_registry_address { + info!(validation_registry = ?addr, "ERC-8004 keeper enabled"); + } else { + info!("ERC-8004 keeper enabled but no ValidationRegistry address configured"); + } + } else { + info!("ERC-8004 keeper disabled"); + } + info!("Keeper initialized"); let l2_client = Arc::new( @@ -123,6 +134,7 @@ async fn main() -> Result<()> { config.l2_heartbeat_manager_address, config.l2_staking_operators_address, config.l2_jailing_policy_address, + config.l2_validation_registry_address, config.private_key.clone(), ) .await?, @@ -164,7 +176,7 @@ async fn main() -> Result<()> { let state = Arc::new(Mutex::new(Default::default())); let l1 = EmissionsSupervisor::new(config.clone(), l2_client.clone()).await?; - let l2 = L2Supervisor::new(l2_client, state.clone()).await?; + let l2 = L2Supervisor::new(l2_client, state.clone(), &config).await?; l2.spawn(config).await?; l1.spawn(); diff --git a/keeper/src/metrics.rs b/keeper/src/metrics.rs index 2633dd4..b94b5dd 100644 --- a/keeper/src/metrics.rs +++ b/keeper/src/metrics.rs @@ -119,6 +119,7 @@ pub(crate) struct L2Metrics { pub(crate) rewards: L2RewardsMetrics, pub(crate) escalations: L2EscalationsMetrics, pub(crate) eth: L2EthMetrics, + pub(crate) erc8004: L2Erc8004Metrics, } impl L2Metrics { @@ -127,11 +128,13 @@ impl L2Metrics { let rewards = L2RewardsMetrics::new(meter); let escalations = L2EscalationsMetrics::new(meter); let eth = L2EthMetrics::new(meter); + let erc8004 = L2Erc8004Metrics::new(meter); Self { events, rewards, escalations, eth, + erc8004, } } } @@ -249,3 +252,33 @@ impl L2EthMetrics { self.funds.record(amount.into(), &[]); } } + +pub(crate) struct L2Erc8004Metrics { + requests_tracked: Gauge, + responses_submitted: Counter, +} + +impl L2Erc8004Metrics { + fn new(meter: &Meter) -> Self { + let requests_tracked = meter + .u64_gauge("blacklight.keeper.l2.erc8004.requests_tracked") + .with_description("Number of ERC-8004 validation requests currently tracked") + .build(); + let responses_submitted = meter + .u64_counter("blacklight.keeper.l2.erc8004.responses_submitted") + .with_description("Total ERC-8004 validation responses submitted") + .build(); + Self { + requests_tracked, + responses_submitted, + } + } + + pub(crate) fn set_requests_tracked(&self, count: u64) { + self.requests_tracked.record(count, &[]); + } + + pub(crate) fn inc_responses_submitted(&self) { + self.responses_submitted.add(1, &[]); + } +} diff --git a/nilcc-simulator/src/args.rs b/nilcc-simulator/src/args.rs deleted file mode 100644 index f321941..0000000 --- a/nilcc-simulator/src/args.rs +++ /dev/null @@ -1,88 +0,0 @@ -use alloy::primitives::Address; -use anyhow::Result; -use clap::Parser; - -use chain_args::{ChainArgs, ChainConfig}; -use state_file::StateFile; -use tracing::info; - -const STATE_FILE_SIMULATOR: &str = "nilcc_simulator.env"; - -/// Default path to HTXs JSON file -const DEFAULT_HTXS_PATH: &str = "data/htxs.json"; - -/// Default slot interval in milliseconds - how often simulator submits HTXs -#[cfg(debug_assertions)] -const DEFAULT_SLOT_MS: u64 = 3000; // 3 seconds for debug (faster testing) - -#[cfg(not(debug_assertions))] -const DEFAULT_SLOT_MS: u64 = 5000; // 5 seconds for release - -/// CLI arguments for the NilCC simulator -#[derive(Parser, Debug)] -#[command(name = "nilcc_simulator")] -#[command(about = "blacklight Server - Submits HTXs to the smart contract", long_about = None)] -pub struct CliArgs { - #[clap(flatten)] - pub chain_args: ChainArgs, - - /// Private key for signing transactions - #[arg(long, env = "PRIVATE_KEY")] - pub private_key: Option, - - /// Path to HTXs JSON file - #[arg(long, env = "HTXS_PATH")] - pub htxs_path: Option, -} - -/// Simulator configuration with all required values resolved -#[derive(Debug, Clone)] -pub struct SimulatorConfig { - pub rpc_url: String, - pub manager_contract_address: Address, - pub staking_contract_address: Address, - pub token_contract_address: Address, - pub private_key: String, - pub htxs_path: String, - pub slot_ms: u64, -} - -impl SimulatorConfig { - /// Load configuration with priority: CLI/env -> state file -> defaults - pub fn load(cli_args: CliArgs) -> Result { - let state_file = StateFile::new(STATE_FILE_SIMULATOR); - let ChainConfig { - rpc_url, - manager_contract_address, - staking_contract_address, - token_contract_address, - } = ChainConfig::new(cli_args.chain_args, &state_file)?; - - // Load private key with priority (different default than node) - let private_key = cli_args - .private_key - .or_else(|| state_file.load_value("PRIVATE_KEY")) - .unwrap_or_else(|| { - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() - }); - - // Load HTXs path with priority - let htxs_path = cli_args - .htxs_path - .or_else(|| state_file.load_value("HTXS_PATH")) - .unwrap_or_else(|| DEFAULT_HTXS_PATH.to_string()); - - info!( - "Loaded SimulatorConfig: rpc_url={rpc_url}, manager_contract_address={manager_contract_address}, htxs_path={htxs_path}" - ); - Ok(SimulatorConfig { - rpc_url, - manager_contract_address, - staking_contract_address, - token_contract_address, - private_key, - htxs_path, - slot_ms: DEFAULT_SLOT_MS, - }) - } -} diff --git a/nilcc-simulator/src/main.rs b/nilcc-simulator/src/main.rs deleted file mode 100644 index 4a125dd..0000000 --- a/nilcc-simulator/src/main.rs +++ /dev/null @@ -1,163 +0,0 @@ -use anyhow::Result; -use args::{CliArgs, SimulatorConfig}; -use blacklight_contract_clients::{ - htx::{Htx, NillionHtx, PhalaHtx}, - {BlacklightClient, ContractConfig}, -}; -use clap::Parser; -use rand::Rng; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::interval; -use tracing::{error, info, warn}; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - -mod args; - -#[tokio::main] -async fn main() -> Result<()> { - init_tracing(); - - let config = load_config()?; - let client = setup_client(&config).await?; - let htxs: Vec = load_htxs(&config.htxs_path); - - run_submission_loop(client, htxs, config.slot_ms).await -} - -fn init_tracing() { - tracing_subscriber::registry() - .with(fmt::layer().with_ansi(true)) - .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) - .init(); -} - -fn load_config() -> Result { - let cli_args = CliArgs::parse(); - let config = SimulatorConfig::load(cli_args)?; - info!(slot_ms = config.slot_ms, "Configuration loaded"); - Ok(config) -} - -async fn setup_client(config: &SimulatorConfig) -> Result { - let contract_config = ContractConfig::new( - config.rpc_url.clone(), - config.manager_contract_address, - config.staking_contract_address, - config.token_contract_address, - ); - - let client = BlacklightClient::new(contract_config, config.private_key.clone()).await?; - - info!( - contract = %client.manager.address(), - signer = %client.signer_address(), - "Connected to contract" - ); - - Ok(client) -} - -fn load_htxs(path: &str) -> Vec { - let htxs_json = std::fs::read_to_string(path).unwrap_or_else(|_| "[]".to_string()); - let htxs: Vec = serde_json::from_str(&htxs_json).unwrap_or_default(); - - if htxs.is_empty() { - warn!(path = %path, "No HTXs loaded"); - } else { - info!(count = htxs.len(), path = %path, "HTXs loaded"); - } - - htxs -} - -async fn run_submission_loop(client: BlacklightClient, htxs: Vec, slot_ms: u64) -> Result<()> { - let mut ticker = interval(Duration::from_millis(slot_ms)); - let mut slot = 0u64; - let client = Arc::new(client); - let htxs = Arc::new(htxs); - - loop { - ticker.tick().await; - slot += 1; - - // Spawn submission as a background task so it doesn't block the next slot - let client = Arc::clone(&client); - let htxs = Arc::clone(&htxs); - tokio::spawn(async move { - if let Err(e) = submit_next_htx(&client, &htxs, slot).await { - error!(slot, error = %e, "Submission failed"); - } - }); - } -} - -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_MS: u64 = 500; - -async fn submit_next_htx( - client: &Arc, - htxs: &Arc>, - slot: u64, -) -> Result<()> { - if htxs.is_empty() { - warn!(slot, "No HTXs available"); - return Ok(()); - } - - let node_count = client.manager.node_count().await?; - if node_count.is_zero() { - warn!(slot, "No nodes registered"); - return Ok(()); - } - - let mut last_error = None; - - for attempt in 0..MAX_RETRIES { - // Randomly select an HTX and make it unique by appending a random nonce to workload_id - // This prevents "HTX already exists" errors when multiple submissions land in the same block - // Scope rng to drop it before await (ThreadRng is not Send) - let htx = { - let mut rng = rand::rng(); - let idx = rng.random_range(0..htxs.len()); - let nonce: u128 = rng.random_range(0..u128::MAX); // 128-bit random number - let mut htx = htxs[idx].clone(); - match &mut htx { - Htx::Nillion(NillionHtx::V1(htx)) => { - htx.workload_id.current = format!("{}-{:x}", htx.workload_id.current, nonce); - } - Htx::Phala(PhalaHtx::V1(htx)) => { - htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); - } - } - htx - }; - - if attempt == 0 { - info!(slot, node_count = %node_count, "Submitting HTX"); - } else { - info!(slot, attempt, "Retrying HTX submission"); - } - - match client.manager.submit_htx(&htx).await { - Ok(tx_hash) => { - info!(slot, tx_hash = ?tx_hash, "HTX submitted"); - return Ok(()); - } - Err(e) => { - let error_str = e.to_string(); - // Only retry on on-chain reverts (state race conditions) - if error_str.contains("reverted on-chain") { - warn!(slot, attempt, error = %e, "Submission reverted, will retry"); - last_error = Some(e); - tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; - continue; - } - // For other errors (simulation failures, etc.), fail immediately - return Err(e); - } - } - } - - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Max retries exceeded"))) -} diff --git a/nilcc-simulator/Cargo.toml b/simulator/Cargo.toml similarity index 83% rename from nilcc-simulator/Cargo.toml rename to simulator/Cargo.toml index 7f80216..42e0137 100644 --- a/nilcc-simulator/Cargo.toml +++ b/simulator/Cargo.toml @@ -1,18 +1,20 @@ [package] -name = "nilcc-simulator" +name = "simulator" version = "0.1.0" edition = "2024" [dependencies] alloy = { version = "1.1", features = ["contract", "providers"] } anyhow = "1.0" +async-trait = "0.1" clap = { version = "4.5", features = ["derive", "env"] } -serde_json = "1.0" rand = "0.9" +serde_json = "1.0" tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } blacklight-contract-clients = { path = "../crates/blacklight-contract-clients" } chain-args = { path = "../crates/chain-args" } +erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } state-file = { path = "../crates/state-file" } diff --git a/simulator/src/common.rs b/simulator/src/common.rs new file mode 100644 index 0000000..33a47c3 --- /dev/null +++ b/simulator/src/common.rs @@ -0,0 +1,83 @@ +use anyhow::{Error, Result}; +use async_trait::async_trait; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::interval; +use tracing::error; + +/// Default slot interval in milliseconds. +#[cfg(debug_assertions)] +pub const DEFAULT_SLOT_MS: u64 = 15000; + +#[cfg(not(debug_assertions))] +pub const DEFAULT_SLOT_MS: u64 = 15000; + +pub const MAX_RETRIES: u32 = 3; +pub const RETRY_DELAY_MS: u64 = 500; + +#[async_trait] +pub trait Simulator: Send + Sync + 'static { + type Args: clap::Args + Send; + + async fn build(args: Self::Args) -> Result + where + Self: Sized; + + fn slot_ms(&self) -> u64; + fn submission_error_message(&self) -> &'static str; + + async fn on_tick(&self, slot: u64) -> Result<()>; +} + +pub async fn run_simulator(args: S::Args) -> Result<()> { + let simulator = Arc::new(S::build(args).await?); + run_slot_loop(simulator).await +} + +async fn run_slot_loop(simulator: Arc) -> Result<()> { + let mut ticker = interval(Duration::from_millis(simulator.slot_ms())); + let mut slot = 0u64; + + loop { + ticker.tick().await; + slot += 1; + + let simulator = Arc::clone(&simulator); + tokio::spawn(async move { + if let Err(e) = simulator.on_tick(slot).await { + error!(slot, error = %e, "{}", simulator.submission_error_message()); + } + }); + } +} + +pub async fn retry_submit(mut action: F, mut on_revert: R) -> Result<()> +where + F: FnMut(u32) -> Fut, + Fut: Future>, + R: FnMut(u32, &Error), +{ + let mut last_error: Option = None; + + for attempt in 0..MAX_RETRIES { + match action(attempt).await { + Ok(()) => return Ok(()), + Err(e) => { + if is_revert_error(&e) { + on_revert(attempt, &e); + last_error = Some(e); + tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; + continue; + } + return Err(e); + } + } + } + + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Max retries exceeded"))) +} + +fn is_revert_error(error: &Error) -> bool { + error.to_string().contains("reverted on-chain") +} diff --git a/simulator/src/erc8004.rs b/simulator/src/erc8004.rs new file mode 100644 index 0000000..86ffb81 --- /dev/null +++ b/simulator/src/erc8004.rs @@ -0,0 +1,243 @@ +use alloy::primitives::{Address, B256, U256, keccak256}; +use anyhow::Result; +use clap::Args; +use erc_8004_contract_clients::{ContractConfig, Erc8004Client}; +use state_file::StateFile; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; + +const STATE_FILE_SIMULATOR: &str = "erc_8004_simulator.env"; + +#[derive(Args, Debug)] +#[command(about = "Register agents and submit ERC-8004 validation requests")] +pub struct Erc8004Args { + /// RPC URL for the Ethereum node + #[arg(long, env = "RPC_URL")] + pub rpc_url: Option, + + /// Address of the IdentityRegistry contract + #[arg(long, env = "IDENTITY_REGISTRY_CONTRACT_ADDRESS")] + pub identity_registry_contract_address: Option, + + /// Address of the ValidationRegistry contract + #[arg(long, env = "VALIDATION_REGISTRY_CONTRACT_ADDRESS")] + pub validation_registry_contract_address: Option, + + /// Private key for signing transactions + #[arg(long, env = "PRIVATE_KEY")] + pub private_key: Option, + + /// Agent URI to register with + #[arg(long, env = "AGENT_URI")] + pub agent_uri: Option, + + /// Validator address that will be authorized to submit validation responses. + /// This should be the address of the keeper or responder service. + #[arg(long, env = "VALIDATOR_ADDRESS")] + pub validator_address: Option, +} + +#[derive(Debug)] +pub struct Erc8004Config { + pub rpc_url: String, + pub identity_registry_contract_address: Address, + pub validation_registry_contract_address: Address, + pub private_key: String, + pub agent_uri: String, + /// The validator address authorized to submit responses (typically the keeper) + pub validator_address: Address, + pub slot_ms: u64, +} + +impl Erc8004Config { + pub fn load(args: Erc8004Args) -> Result { + let state_file = StateFile::new(STATE_FILE_SIMULATOR); + + let rpc_url = args + .rpc_url + .or_else(|| state_file.load_value("RPC_URL")) + .unwrap_or_else(|| "http://127.0.0.1:8545".to_string()); + + let identity_registry_contract_address = args + .identity_registry_contract_address + .or_else(|| state_file.load_value("IDENTITY_REGISTRY_CONTRACT_ADDRESS")) + .unwrap_or_else(|| "0x5FbDB2315678afecb367f032d93F642f64180aa3".to_string()) + .parse::

()?; + + let validation_registry_contract_address = args + .validation_registry_contract_address + .or_else(|| state_file.load_value("VALIDATION_REGISTRY_CONTRACT_ADDRESS")) + .unwrap_or_else(|| "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".to_string()) + .parse::
()?; + + let private_key = args + .private_key + .or_else(|| state_file.load_value("PRIVATE_KEY")) + .unwrap_or_else(|| { + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() + }); + + let agent_uri = args + .agent_uri + .or_else(|| state_file.load_value("AGENT_URI")) + .unwrap_or_else(|| "https://example.com/agent".to_string()); + + let validator_address = args + .validator_address + .or_else(|| state_file.load_value("VALIDATOR_ADDRESS")) + .unwrap_or_else(|| "0x90F79bf6EB2c4f870365E785982E1f101E93b906".to_string()) + .parse::
()?; + + info!( + "Loaded Erc8004Config: rpc_url={rpc_url}, identity_registry={identity_registry_contract_address}, validation_registry={validation_registry_contract_address}, validator={validator_address}" + ); + + Ok(Self { + rpc_url, + identity_registry_contract_address, + validation_registry_contract_address, + private_key, + agent_uri, + validator_address, + slot_ms: DEFAULT_SLOT_MS, + }) + } +} + +pub struct Erc8004Simulator { + client: Arc, + config: Arc, + agent_id: U256, +} + +async fn setup_client(config: &Erc8004Config) -> Result { + let contract_config = ContractConfig::new( + config.rpc_url.clone(), + config.identity_registry_contract_address, + config.validation_registry_contract_address, + ); + + let client = Erc8004Client::new(contract_config, config.private_key.clone()).await?; + info!( + identity_registry = %client.identity_registry.address(), + validation_registry = %client.validation_registry.address(), + signer = %client.signer_address(), + "Connected to contracts" + ); + Ok(client) +} + +async fn register_agent(client: &Erc8004Client, config: &Erc8004Config) -> Result { + info!(agent_uri = %config.agent_uri, "Registering agent"); + + let (tx_hash, agent_id) = client + .identity_registry + .register_with_uri_and_get_id(config.agent_uri.clone()) + .await?; + + info!(tx_hash = ?tx_hash, agent_id = %agent_id, "Agent registration transaction submitted"); + + match client.identity_registry.get_agent(agent_id).await { + Ok((owner, uri, wallet)) => { + info!( + agent_id = %agent_id, + owner = %owner, + uri = %uri, + wallet = %wallet, + "Agent registered successfully" + ); + } + Err(e) => { + warn!(agent_id = %agent_id, error = %e, "Could not verify agent registration"); + } + } + + Ok(agent_id) +} + +impl Erc8004Simulator { + async fn submit_validation_request(&self, slot: u64) -> Result<()> { + let client = Arc::clone(&self.client); + let config = Arc::clone(&self.config); + let agent_id = self.agent_id; + + retry_submit( + move |attempt| { + let client = Arc::clone(&client); + let config = Arc::clone(&config); + async move { + let block_number = client.get_block_number().await?; + let snapshot_id = block_number.saturating_sub(1); + + let request_uri = config.agent_uri.clone(); + let hash_input = format!("{}:{}", request_uri, snapshot_id); + let request_hash = B256::from(keccak256(hash_input.as_bytes())); + + if attempt == 0 { + info!( + slot, + agent_id = %agent_id, + validator = %config.validator_address, + snapshot_id = snapshot_id, + request_uri = %request_uri, + "Submitting validation request" + ); + } else { + info!(slot, attempt, "Retrying validation request submission"); + } + + let tx_hash = client + .validation_registry + .validation_request( + config.validator_address, + agent_id, + request_uri, + request_hash, + snapshot_id, + ) + .await?; + + info!(slot, tx_hash = ?tx_hash, "Validation request submitted"); + Ok(()) + } + }, + move |attempt, error| { + warn!(slot, attempt, error = %error, "Submission reverted, will retry"); + }, + ) + .await + } +} + +#[async_trait::async_trait] +impl Simulator for Erc8004Simulator { + type Args = Erc8004Args; + + async fn build(args: Self::Args) -> Result { + let config = Erc8004Config::load(args)?; + info!(slot_ms = config.slot_ms, "Configuration loaded"); + + let client = setup_client(&config).await?; + let agent_id = register_agent(&client, &config).await?; + + Ok(Self { + client: Arc::new(client), + config: Arc::new(config), + agent_id, + }) + } + + fn slot_ms(&self) -> u64 { + self.config.slot_ms + } + + fn submission_error_message(&self) -> &'static str { + "ERC-8004 validation request submission failed" + } + + async fn on_tick(&self, slot: u64) -> Result<()> { + self.submit_validation_request(slot).await + } +} diff --git a/simulator/src/main.rs b/simulator/src/main.rs new file mode 100644 index 0000000..dd26f64 --- /dev/null +++ b/simulator/src/main.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use clap::Parser; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +mod common; +mod erc8004; +mod nilcc; + +#[derive(Parser, Debug)] +#[command(name = "simulator")] +#[command(about = "Blacklight simulators: HTX submission (nilcc) and ERC-8004 validation requests", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Submit HTXs to the HeartbeatManager contract (nilCC attestations) + Nilcc(nilcc::NilccArgs), + /// Register agents and submit ERC-8004 validation requests + Erc8004(erc8004::Erc8004Args), +} + +fn init_tracing() { + tracing_subscriber::registry() + .with(fmt::layer().with_ansi(true)) + .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let cli = Cli::parse(); + match cli.command { + Command::Nilcc(args) => common::run_simulator::(args).await, + Command::Erc8004(args) => common::run_simulator::(args).await, + } +} diff --git a/simulator/src/nilcc.rs b/simulator/src/nilcc.rs new file mode 100644 index 0000000..5dc5735 --- /dev/null +++ b/simulator/src/nilcc.rs @@ -0,0 +1,206 @@ +use alloy::primitives::Address; +use anyhow::Result; +use blacklight_contract_clients::{ + BlacklightClient, ContractConfig, + htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, +}; +use chain_args::{ChainArgs, ChainConfig}; +use clap::Args; +use rand::Rng; +use state_file::StateFile; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; + +const STATE_FILE_SIMULATOR: &str = "nilcc_simulator.env"; + +/// Default path to HTXs JSON file +const DEFAULT_HTXS_PATH: &str = "data/htxs.json"; + +#[derive(Args, Debug)] +#[command(about = "Submit HTXs to the HeartbeatManager contract")] +pub struct NilccArgs { + #[command(flatten)] + pub chain_args: ChainArgs, + + /// Private key for signing transactions + #[arg(long, env = "PRIVATE_KEY")] + pub private_key: Option, + + /// Path to HTXs JSON file + #[arg(long, env = "HTXS_PATH")] + pub htxs_path: Option, +} + +#[derive(Debug, Clone)] +pub struct NilccConfig { + pub rpc_url: String, + pub manager_contract_address: Address, + pub staking_contract_address: Address, + pub token_contract_address: Address, + pub private_key: String, + pub htxs_path: String, + pub slot_ms: u64, +} + +pub struct NilccSimulator { + client: Arc, + htxs: Arc>, + slot_ms: u64, +} + +impl NilccConfig { + pub fn load(args: NilccArgs) -> Result { + let state_file = StateFile::new(STATE_FILE_SIMULATOR); + let ChainConfig { + rpc_url, + manager_contract_address, + staking_contract_address, + token_contract_address, + } = ChainConfig::new(args.chain_args, &state_file)?; + + let private_key = args + .private_key + .or_else(|| state_file.load_value("PRIVATE_KEY")) + .unwrap_or_else(|| { + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() + }); + + let htxs_path = args + .htxs_path + .or_else(|| state_file.load_value("HTXS_PATH")) + .unwrap_or_else(|| DEFAULT_HTXS_PATH.to_string()); + + info!( + "Loaded NilccConfig: rpc_url={rpc_url}, manager_contract_address={manager_contract_address}, htxs_path={htxs_path}" + ); + Ok(Self { + rpc_url, + manager_contract_address, + staking_contract_address, + token_contract_address, + private_key, + htxs_path, + slot_ms: DEFAULT_SLOT_MS, + }) + } +} + +fn load_htxs(path: &str) -> Vec { + let htxs_json = std::fs::read_to_string(path).unwrap_or_else(|_| "[]".to_string()); + let json_htxs: Vec = serde_json::from_str(&htxs_json).unwrap_or_default(); + let htxs: Vec = json_htxs.into_iter().map(Htx::from).collect(); + + if htxs.is_empty() { + warn!(path = %path, "No HTXs loaded"); + } else { + info!(count = htxs.len(), path = %path, "HTXs loaded"); + } + + htxs +} + +impl NilccSimulator { + async fn submit_next_htx(&self, slot: u64) -> Result<()> { + let client = Arc::clone(&self.client); + let htxs = Arc::clone(&self.htxs); + + if htxs.is_empty() { + warn!(slot, "No HTXs available"); + return Ok(()); + } + + let node_count = client.manager.node_count().await?; + if node_count.is_zero() { + warn!(slot, "No nodes registered"); + return Ok(()); + } + + retry_submit( + move |attempt| { + let client = Arc::clone(&client); + let htxs = Arc::clone(&htxs); + async move { + let htx = { + let mut rng = rand::rng(); + let idx = rng.random_range(0..htxs.len()); + let nonce: u128 = rng.random_range(0..u128::MAX); + let mut htx = htxs[idx].clone(); + match &mut htx { + Htx::Nillion(NillionHtx::V1(htx)) => { + htx.workload_id.current = + format!("{}-{:x}", htx.workload_id.current, nonce); + } + Htx::Phala(PhalaHtx::V1(htx)) => { + htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); + } + Htx::Erc8004(_) => { + unreachable!("ERC-8004 HTXs should not be loaded from JSON files") + } + } + htx + }; + + if attempt == 0 { + info!(slot, node_count = %node_count, "Submitting HTX"); + } else { + info!(slot, attempt, "Retrying HTX submission"); + } + + let tx_hash = client.manager.submit_htx(&htx).await?; + info!(slot, tx_hash = ?tx_hash, "HTX submitted"); + Ok(()) + } + }, + move |attempt, error| { + warn!(slot, attempt, error = %error, "Submission reverted, will retry"); + }, + ) + .await + } +} + +#[async_trait::async_trait] +impl Simulator for NilccSimulator { + type Args = NilccArgs; + + async fn build(args: Self::Args) -> Result { + let config = NilccConfig::load(args)?; + info!(slot_ms = config.slot_ms, "Configuration loaded"); + + let contract_config = ContractConfig::new( + config.rpc_url.clone(), + config.manager_contract_address, + config.staking_contract_address, + config.token_contract_address, + ); + + let client = BlacklightClient::new(contract_config, config.private_key.clone()).await?; + info!( + contract = %client.manager.address(), + signer = %client.signer_address(), + "Connected to contract" + ); + + let htxs = load_htxs(&config.htxs_path); + + Ok(Self { + client: Arc::new(client), + htxs: Arc::new(htxs), + slot_ms: config.slot_ms, + }) + } + + fn slot_ms(&self) -> u64 { + self.slot_ms + } + + fn submission_error_message(&self) -> &'static str { + "NilCC submission failed" + } + + async fn on_tick(&self, slot: u64) -> Result<()> { + self.submit_next_htx(slot).await + } +}