From 4abd97b6eea8372ab0c2c4a9d14fdd9e753c87fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 30 Jan 2026 15:54:46 +0100 Subject: [PATCH 01/17] feat: integrate ERC 8004 HTX parsing and validation --- Cargo.toml | 2 + blacklight-node/src/main.rs | 21 +- blacklight-node/src/verification.rs | 50 +- crates/blacklight-contract-clients/Cargo.toml | 1 + crates/blacklight-contract-clients/src/htx.rs | 207 ++++++- crates/erc-8004-contract-clients/Cargo.toml | 15 + .../src/common/errors.rs | 562 ++++++++++++++++++ .../src/common/event_helper.rs | 188 ++++++ .../src/common/mod.rs | 22 + .../src/common/tx_submitter.rs | 160 +++++ .../src/erc_8004_client.rs | 97 +++ .../src/identity_registry.rs | 121 ++++ crates/erc-8004-contract-clients/src/lib.rs | 125 ++++ .../src/validation_registry.rs | 85 +++ docker-compose.yml | 54 +- docker/Dockerfile | 7 + erc-8004-simulator/Cargo.toml | 16 + erc-8004-simulator/src/args.rs | 119 ++++ erc-8004-simulator/src/main.rs | 180 ++++++ nilcc-simulator/src/main.rs | 9 +- 20 files changed, 2019 insertions(+), 22 deletions(-) create mode 100644 crates/erc-8004-contract-clients/Cargo.toml create mode 100644 crates/erc-8004-contract-clients/src/common/errors.rs create mode 100644 crates/erc-8004-contract-clients/src/common/event_helper.rs create mode 100644 crates/erc-8004-contract-clients/src/common/mod.rs create mode 100644 crates/erc-8004-contract-clients/src/common/tx_submitter.rs create mode 100644 crates/erc-8004-contract-clients/src/erc_8004_client.rs create mode 100644 crates/erc-8004-contract-clients/src/identity_registry.rs create mode 100644 crates/erc-8004-contract-clients/src/lib.rs create mode 100644 crates/erc-8004-contract-clients/src/validation_registry.rs create mode 100644 erc-8004-simulator/Cargo.toml create mode 100644 erc-8004-simulator/src/args.rs create mode 100644 erc-8004-simulator/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index 6593041..83a76cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,9 @@ members = [ "blacklight-node", "crates/blacklight-contract-clients", "crates/chain-args", + "crates/erc-8004-contract-clients", "crates/state-file", + "erc-8004-simulator", "keeper", "monitor", "nilcc-simulator" diff --git a/blacklight-node/src/main.rs b/blacklight-node/src/main.rs index 831f198..d1cded5 100644 --- a/blacklight-node/src/main.rs +++ b/blacklight-node/src/main.rs @@ -105,8 +105,16 @@ async fn process_htx_assignment( 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) { + // Debug: log the raw HTX bytes + let raw_bytes: &[u8] = &event.rawHTX; + tracing::debug!( + htx_id = ?htx_id, + raw_len = raw_bytes.len(), + raw_hex = %alloy::hex::encode(raw_bytes), + "Raw HTX bytes" + ); + // 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"); @@ -116,6 +124,15 @@ async fn process_htx_assignment( info!(htx_id = ?htx_id, "Detected Phala HTX"); 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" + ); + verifier.verify_erc8004_htx(&htx).await + } }, Err(e) => { error!(htx_id = ?htx_id, error = %e, "Failed to parse HTX data"); 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..3091f07 100644 --- a/crates/blacklight-contract-clients/Cargo.toml +++ b/crates/blacklight-contract-clients/Cargo.toml @@ -11,5 +11,6 @@ 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/htx.rs b/crates/blacklight-contract-clients/src/htx.rs index f5bab46..810aa06 100644 --- a/crates/blacklight-contract-clients/src/htx.rs +++ b/crates/blacklight-contract-clients/src/htx.rs @@ -1,4 +1,6 @@ -use alloy::primitives::Bytes; +use alloy::dyn_abi::{DynSolType, DynSolValue}; +use alloy::primitives::{Address, B256, Bytes, U256}; +use alloy::sol_types::SolValue; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use serde_with::{hex::Hex, serde_as}; @@ -79,27 +81,195 @@ pub enum PhalaHtx { V1(PhalaHtxV1), } -// Unified HTX type that can represent both nilCC and Phala HTXs +// ERC-8004 Validation HTX - ABI encoded from ValidationRegistry +// Solidity: abi.encode(validatorAddress, agentId, requestURI, requestHash) + +/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes +#[derive(Debug, Clone)] +pub struct Erc8004Htx { + pub validator_address: Address, + pub agent_id: U256, + pub request_uri: String, + pub request_hash: B256, +} + +impl Erc8004Htx { + /// Try to decode ABI-encoded ERC-8004 validation data + /// Format: abi.encode(validatorAddress, agentId, requestURI, requestHash) + pub fn try_decode(data: &[u8]) -> Result { + // Use DynSolType::Tuple for proper ABI decoding of abi.encode() output + // abi.encode() produces parameter encoding, so we use abi_decode_params on a tuple + let tuple_type = DynSolType::Tuple(vec![ + DynSolType::Address, + DynSolType::Uint(256), + DynSolType::String, + DynSolType::FixedBytes(32), + ]); + + let decoded = tuple_type + .abi_decode_params(data) + .map_err(|e| Erc8004DecodeError(e.to_string()))?; + + // Extract values from the decoded tuple + let values = match decoded { + DynSolValue::Tuple(values) => values, + _ => return Err(Erc8004DecodeError("Expected tuple".to_string())), + }; + + if values.len() != 4 { + return Err(Erc8004DecodeError(format!( + "Expected 4 values, got {}", + values.len() + ))); + } + + let validator_address = match &values[0] { + DynSolValue::Address(addr) => *addr, + _ => return Err(Erc8004DecodeError("Expected address".to_string())), + }; + + let agent_id = match &values[1] { + DynSolValue::Uint(val, _) => *val, + _ => return Err(Erc8004DecodeError("Expected uint256".to_string())), + }; + + let request_uri = match &values[2] { + DynSolValue::String(s) => s.clone(), + _ => return Err(Erc8004DecodeError("Expected string".to_string())), + }; + + let request_hash = match &values[3] { + DynSolValue::FixedBytes(word, 32) => B256::from_slice(word.as_slice()), + _ => return Err(Erc8004DecodeError("Expected bytes32".to_string())), + }; + + Ok(Self { + validator_address, + agent_id, + request_uri, + request_hash, + }) + } +} + +#[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 {} + +// Unified HTX type that can represent nilCC, Phala, and ERC-8004 HTXs +#[derive(Debug, Clone)] +pub enum Htx { + Nillion(NillionHtx), + Phala(PhalaHtx), + Erc8004(Erc8004Htx), +} + +/// JSON-serializable HTX types (Nillion and Phala only, not ERC-8004) +/// Use this for loading HTXs from JSON files. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "provider", rename_all = "camelCase")] -pub enum Htx { +pub enum JsonHtx { Nillion(NillionHtx), Phala(PhalaHtx), } +impl From for Htx { + fn from(htx: JsonHtx) -> Self { + match htx { + JsonHtx::Nillion(htx) => Htx::Nillion(htx), + JsonHtx::Phala(htx) => Htx::Phala(htx), + } + } +} + +impl Htx { + /// Parse HTX from raw bytes, trying JSON first then ABI decoding + pub fn try_parse(data: &[u8]) -> Result { + // First try JSON parsing (nilCC and Phala) + match serde_json::from_slice::(data) { + Ok(json_htx) => { + return Ok(match json_htx { + JsonHtx::Nillion(htx) => Htx::Nillion(htx), + JsonHtx::Phala(htx) => Htx::Phala(htx), + }); + } + Err(json_err) => { + tracing::debug!(error = %json_err, "JSON parsing failed, trying ABI decode"); + } + } + + // Then try ABI decoding (ERC-8004) + match Erc8004Htx::try_decode(data) { + Ok(erc8004_htx) => { + return Ok(Htx::Erc8004(erc8004_htx)); + } + Err(abi_err) => { + tracing::debug!(error = %abi_err, data_len = data.len(), "ABI decoding failed"); + } + } + + Err(HtxParseError::UnknownFormat) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum HtxParseError { + #[error("Unknown HTX format: not valid JSON or ABI-encoded ERC-8004")] + UnknownFormat, +} + impl From for Htx { fn from(htx: NillionHtx) -> Self { Htx::Nillion(htx) } } +impl From for Htx { + fn from(htx: PhalaHtx) -> Self { + Htx::Phala(htx) + } +} + +impl From for Htx { + fn from(htx: Erc8004Htx) -> Self { + Htx::Erc8004(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())) + match htx { + Htx::Nillion(htx) => { + let json_htx = JsonHtx::Nillion(htx.clone()); + let json = canonicalize_json(&serde_json::to_value(json_htx)?); + let json = serde_json::to_string(&json)?; + Ok(Bytes::from(json.into_bytes())) + } + Htx::Phala(htx) => { + let json_htx = JsonHtx::Phala(htx.clone()); + let json = canonicalize_json(&serde_json::to_value(json_htx)?); + let json = serde_json::to_string(&json)?; + Ok(Bytes::from(json.into_bytes())) + } + Htx::Erc8004(htx) => { + let tuple = ( + htx.validator_address, + htx.agent_id, + htx.request_uri.clone(), + htx.request_hash, + ); + Ok(Bytes::from(tuple.abi_encode())) + } + } } } @@ -212,8 +382,8 @@ mod tests { } }"#; - let htx: Htx = serde_json::from_str(phala_json).unwrap(); - let Htx::Phala(PhalaHtx::V1(htx)) = htx else { + 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"); @@ -240,7 +410,24 @@ mod tests { } }"#; - let htx: Htx = serde_json::from_str(nilcc_json).unwrap(); - assert!(matches!(htx, Htx::Nillion(_)), "not a nillion HTX"); + let htx: JsonHtx = serde_json::from_str(nilcc_json).unwrap(); + assert!(matches!(htx, JsonHtx::Nillion(_)), "not a nillion HTX"); + } + + #[test] + fn test_erc8004_decode() { + // 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, U256::ZERO); + assert_eq!(htx.request_uri, "https://api.nilai.nillion.network/"); } } diff --git a/crates/erc-8004-contract-clients/Cargo.toml b/crates/erc-8004-contract-clients/Cargo.toml new file mode 100644 index 0000000..83df7a9 --- /dev/null +++ b/crates/erc-8004-contract-clients/Cargo.toml @@ -0,0 +1,15 @@ +[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"] } +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/common/errors.rs b/crates/erc-8004-contract-clients/src/common/errors.rs new file mode 100644 index 0000000..3975846 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/common/errors.rs @@ -0,0 +1,562 @@ +//! # Error Handling for Solidity Revert Data +//! +//! This module provides type-safe decoding of Solidity revert data using the Alloy library. +//! When a smart contract transaction fails (reverts), the EVM returns encoded error data. +//! This module decodes that raw hex data into human-readable error messages. +//! +//! ## Supported Error Types +//! +//! 1. **Standard `Error(string)`** - From `require(condition, "message")` statements +//! - Selector: `0x08c379a0` +//! - Most common error type in Solidity contracts +//! +//! 2. **Standard `Panic(uint256)`** - From `assert()` failures and arithmetic errors +//! - Selector: `0x4e487b71` +//! - Includes overflow, division by zero, array bounds, etc. +//! +//! 3. **Custom Contract Errors** - Gas-efficient custom errors from contract ABIs +//! - Add support here when custom errors are introduced in registry contracts +//! - Each error has a unique 4-byte selector derived from its signature +//! +//! ## Usage Flow +//! +//! ```text +//! Transaction reverts → RPC returns error with hex data → +//! decode_any_error() → try_extract_from_string() → decode_revert() → +//! → "contract: request already exists" (human-readable!) +//! ``` +//! +//! ## Example +//! +//! Instead of seeing: +//! ```text +//! error: execution reverted: 0x08c379a0000000... +//! ``` +//! +//! You now see: +//! ```text +//! error: contract: request already exists +//! ``` +//! +//! ## Main Entry Points +//! +//! - [`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 + +use alloy::{ + contract::Error as ContractError, hex, primitives::Bytes, sol, sol_types::SolInterface, + transports::TransportError, +}; + +// ============================================================================ +// Standard Solidity Errors +// ============================================================================ +// +// The `sol!` macro generates Rust types that can decode ABI-encoded error data. +// Each error has a unique 4-byte "selector" (first 4 bytes of keccak256(signature)) +// that identifies it in the raw revert data. + +sol! { + /// Standard Solidity errors used by the EVM. + /// + /// - `Error(string)` - Produced by `require(condition, "message")` when condition is false + /// Selector: `0x08c379a0` = keccak256("Error(string)")[:4] + /// + /// - `Panic(uint256)` - Produced by `assert()` failures, arithmetic errors, etc. + /// Selector: `0x4e487b71` = keccak256("Panic(uint256)")[:4] + #[derive(Debug, PartialEq, Eq)] + library StandardErrors { + error Error(string message); + error Panic(uint256 code); + } +} + +// ============================================================================ +// 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 + +// Note: If custom errors are added to the registry contracts, re-export them here +// and extend `decode_revert` with the appropriate decoding. + +// ============================================================================ +// DecodedRevert Enum - The Result of Decoding +// ============================================================================ + +/// Represents the result of decoding Solidity revert data. +/// +/// This enum captures all possible outcomes when attempting to decode raw revert bytes: +/// +/// | Variant | When it's used | +/// |---------|----------------| +/// | `ErrorString` | `require()` failed with a message | +/// | `Panic` | `assert()` failed or arithmetic error | +/// | `RawRevert` | We got hex data but couldn't decode it | +/// | `NoRevertData` | No revert data at all (unusual) | +#[derive(Debug, Clone)] +pub enum DecodedRevert { + /// Standard `Error(string)` from `require(condition, "message")` statements. + /// This is the most common error type in Solidity contracts. + ErrorString(String), + + /// Panic error with a numeric panic code. + /// Produced by `assert()` failures, arithmetic overflow, division by zero, etc. + /// See [`panic_reason`] for code meanings. + Panic(u64), + + /// Raw revert data that couldn't be decoded by any known error type. + /// Contains the hex bytes so the user can manually debug. + RawRevert(Bytes), + + /// No revert data was available in the error. + /// This is unexpected for contract reverts - includes context about why. + NoRevertData(String), +} + +impl std::fmt::Display for DecodedRevert { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DecodedRevert::ErrorString(msg) => write!(f, "{}", msg), + DecodedRevert::Panic(code) => write!(f, "Panic({}): {}", code, panic_reason(*code)), + DecodedRevert::RawRevert(data) => write!(f, "Raw revert data: {}", data), + DecodedRevert::NoRevertData(details) => write!(f, "No revert data ({})", details), + } + } +} + +// ============================================================================ +// Panic Code Meanings +// ============================================================================ + +/// Get human-readable reason for Solidity panic codes. +/// +/// Panic codes are defined in the Solidity documentation: +/// +/// +/// # Panic Codes +/// +/// | Code | Meaning | +/// |------|---------| +/// | 0x00 | Generic compiler panic | +/// | 0x01 | `assert()` failure | +/// | 0x11 | Arithmetic overflow/underflow | +/// | 0x12 | Division by zero | +/// | 0x21 | Invalid enum value | +/// | 0x22 | Storage byte array encoding error | +/// | 0x31 | `pop()` on empty array | +/// | 0x32 | Array index out of bounds | +/// | 0x41 | Memory allocation overflow | +/// | 0x51 | Zero-initialized function pointer call | +fn panic_reason(code: u64) -> &'static str { + match code { + 0x00 => "generic compiler panic", + 0x01 => "assertion failed", + 0x11 => "arithmetic overflow/underflow", + 0x12 => "division by zero", + 0x21 => "invalid enum value", + 0x22 => "storage byte array encoding error", + 0x31 => "pop on empty array", + 0x32 => "array index out of bounds", + 0x41 => "memory allocation overflow", + 0x51 => "zero-initialized function pointer call", + _ => "unknown panic code", + } +} + +// ============================================================================ +// Core Decoding Logic +// ============================================================================ + +/// Decode raw revert data bytes into a human-readable error. +/// +/// 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 +/// 4. **Fallback** - Return the raw hex so user can debug +/// +/// # Arguments +/// +/// * `data` - Raw ABI-encoded revert data from the EVM +/// +/// # Returns +/// +/// A [`DecodedRevert`] variant representing the decoded error. +/// +/// # Example +/// +/// ```ignore +/// let revert_data = Bytes::from(hex::decode("08c379a0...").unwrap()); +/// let decoded = decode_revert(&revert_data); +/// println!("Error: {}", decoded); // "contract: request already exists" +/// ``` +pub fn decode_revert(data: &Bytes) -> DecodedRevert { + // Empty revert data is unusual - contracts normally include some data + if data.is_empty() { + return DecodedRevert::NoRevertData("empty revert data".to_string()); + } + + // Step 1: Try to decode as standard Error(string) or Panic(uint256) + // The abi_decode method checks the 4-byte selector and decodes the rest + if let Ok(err) = StandardErrors::StandardErrorsErrors::abi_decode(data) { + match err { + StandardErrors::StandardErrorsErrors::Error(e) => { + return DecodedRevert::ErrorString(e.message); + } + StandardErrors::StandardErrorsErrors::Panic(p) => { + // Panic code is a uint256, but we only care about the low bits + return DecodedRevert::Panic(p.code.try_into().unwrap_or(0)); + } + } + } + + // Step 2: Unknown error - return raw bytes so user can debug + // This allows users to manually decode or report the unknown error type + DecodedRevert::RawRevert(data.clone()) +} + +// ============================================================================ +// Alloy ContractError Extraction +// ============================================================================ +// +// Alloy wraps errors in ContractError, which contains TransportError for RPC errors. +// The revert data is often buried in the TransportError's ErrorResp data field. +// This section extracts that data properly instead of relying on string parsing. + +/// Extract and decode revert data from an Alloy [`ContractError`]. +/// +/// This is the **proper** way to get revert data from Alloy errors - it accesses +/// the structured error fields directly rather than parsing strings. +/// +/// # How It Works +/// +/// 1. For `TransportError`: Extracts hex data from the RPC error response +/// 2. For `AbiError`: Returns the ABI encoding error (rare) +/// 3. For other types: Falls back to parsing the Debug representation +/// +/// # Arguments +/// +/// * `error` - The Alloy contract error to extract revert data from +/// +/// # Returns +/// +/// A [`DecodedRevert`] with the decoded error or context about why decoding failed. +pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRevert { + match error { + // TransportError is the most common - it wraps RPC error responses + ContractError::TransportError(transport_err) => { + extract_revert_from_transport_error(transport_err) + } + // ABI errors happen when encoding/decoding fails (rare) + ContractError::AbiError(abi_err) => { + DecodedRevert::NoRevertData(format!("ABI error: {}", abi_err)) + } + // For other error types, try to extract from the Debug representation + // 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) { + decoded + } else { + DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) + } + } + } +} + +/// Extract revert data from an Alloy [`TransportError`]. +/// +/// The RPC response for a reverted transaction typically includes: +/// - `ErrorResp.data`: Hex-encoded revert data (what we want) +/// - `ErrorResp.message`: Human-readable error message from the RPC node +/// +/// # Arguments +/// +/// * `error` - The transport error from the RPC layer +/// +/// # Returns +/// +/// A [`DecodedRevert`] with the decoded error data. +fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert { + match error { + TransportError::ErrorResp(err_resp) => { + // The error response may contain revert data in the `data` field + if let Some(data) = &err_resp.data { + // Get the raw JSON value as a string + let data_str = data.get(); + // Remove JSON quotes if present (RPC returns "0x..." as a JSON string) + let data_str = data_str.trim_matches('"'); + + // Try to parse as hex string starting with 0x + if let Some(hex_data) = data_str.strip_prefix("0x") + && let Ok(bytes) = hex::decode(hex_data) + { + return decode_revert(&Bytes::from(bytes)); + } + // If not hex, include the raw data for debugging + return DecodedRevert::NoRevertData(format!("Error data: {}", data_str)); + } + // No data field, but we have the RPC error message + DecodedRevert::NoRevertData(format!("RPC error: {}", err_resp.message)) + } + // 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) { + decoded + } else { + DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) + } + } + } +} + +// ============================================================================ +// String Pattern Extraction (Fallback) +// ============================================================================ +// +// Different RPC providers format error messages differently. This fallback +// searches the error string for hex patterns that might contain revert data. +// This is less reliable than structured extraction but catches edge cases. + +/// Try to extract revert data from an error string (fallback mechanism). +/// +/// Different RPC providers format errors differently: +/// - `execution reverted: 0x08c379a0...` +/// - `reverted with data: 0x...` +/// - `data: 0x...` +/// +/// This function searches for these patterns and extracts the hex data. +/// +/// # Arguments +/// +/// * `error_str` - The error message string to search +/// +/// # Returns +/// +/// `Some(DecodedRevert)` if hex data was found and decoded, `None` otherwise. +fn try_extract_from_string(error_str: &str) -> Option { + // Patterns that indicate hex revert data follows + const PATTERNS: &[&str] = &[ + "execution reverted: 0x", + "reverted with data: 0x", + "revert data: 0x", + "data: 0x", + "0x08c379a0", // Error(string) selector - direct match + "0x4e487b71", // Panic(uint256) selector - direct match + ]; + + for pattern in PATTERNS { + if let Some(start) = error_str.find(pattern) { + // Calculate where the hex data starts + let hex_start = if pattern.ends_with("0x") { + start + pattern.len() - 2 // Include the 0x prefix + } else { + start // Pattern IS the start of hex data + }; + + let remaining = &error_str[hex_start..]; + + // Find the end of the hex string (only hex chars after 0x) + let hex_end = if remaining.starts_with("0x") { + 2 + remaining + .strip_prefix("0x") + .unwrap_or(remaining) + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .count() + } else { + remaining + .chars() + .take_while(|c| c.is_ascii_hexdigit()) + .count() + }; + + let hex_str = &remaining[..hex_end]; + // Need at least 0x + 4 bytes (8 hex chars) for a valid selector + 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))); + } + } + } + } + + // Special case: plain text error message after "execution reverted:" + // Some RPC providers return: `execution reverted: contract: request already exists` + if error_str.contains("execution reverted") + && let Some(idx) = error_str.find("execution reverted:") + { + let after = &error_str[idx + 19..]; // Skip "execution reverted:" + let msg = after.trim().trim_matches('"').trim(); + // Only use if it's not hex data (already handled above) + if !msg.is_empty() && !msg.starts_with("0x") { + return Some(DecodedRevert::ErrorString(msg.to_string())); + } + } + + None +} + +// ============================================================================ +// Generic Entry Points +// ============================================================================ + +/// Decode ANY error into a human-readable message. +/// +/// This is the **main entry point** for error decoding. It accepts any error type +/// that implements `Display` and `Debug`, and tries its best to extract revert data. +/// +/// # Strategy +/// +/// 1. Try extracting from the `Display` string representation +/// 2. Try extracting from the `Debug` string representation (often has more details) +/// 3. If nothing works, return the raw error so the user can see what's happening +/// +/// # Arguments +/// +/// * `error` - Any error type implementing `Display` and `Debug` +/// +/// # Returns +/// +/// A [`DecodedRevert`] - never panics, always returns something useful. +/// +/// # Example +/// +/// ```ignore +/// let decoded = decode_any_error(&some_error); +/// log::error!("Transaction failed: {}", decoded); +/// ``` +pub fn decode_any_error(error: &E) -> DecodedRevert { + 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) { + return decoded; + } + + // Then try the Debug representation (often has more details like struct fields) + if let Some(decoded) = try_extract_from_string(&debug_str) { + return decoded; + } + + // If nothing works, return the error as-is so user can see what's happening + DecodedRevert::NoRevertData(format!( + "Could not extract revert data. Raw error: {}", + error_str + )) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + /// Test decoding a standard Error(string) from require() statements. + /// + /// ABI encoding format for Error(string): + /// - Bytes 0-3: Selector (0x08c379a0) + /// - Bytes 4-35: Offset to string data (always 0x20 = 32) + /// - Bytes 36-67: String length + /// - Bytes 68+: UTF-8 string data (padded to 32 bytes) + #[test] + fn test_decode_error_string() { + // "NilAV: unknown HTX" encoded as Error(string) + // Selector: 08c379a0 + // Offset: 0000...0020 (32 bytes) + // Length: 0000...0012 (18 bytes = "NilAV: unknown HTX".len()) + // Data: 4e696c41563a20756e6b6e6f776e20485458 + padding + let data = hex::decode( + "08c379a0\ + 0000000000000000000000000000000000000000000000000000000000000020\ + 0000000000000000000000000000000000000000000000000000000000000012\ + 4e696c41563a20756e6b6e6f776e204854580000000000000000000000000000", + ) + .unwrap(); + + let decoded = decode_revert(&Bytes::from(data)); + + match decoded { + DecodedRevert::ErrorString(msg) => { + assert_eq!(msg, "NilAV: unknown HTX"); + } + _ => panic!("Expected ErrorString, got {:?}", decoded), + } + } + + /// Test decoding a Panic(uint256) from assert() failures. + /// + /// ABI encoding format for Panic(uint256): + /// - Bytes 0-3: Selector (0x4e487b71) + /// - Bytes 4-35: Panic code as uint256 + #[test] + fn test_decode_panic() { + // Panic(1) - assert failure + // Selector: 4e487b71 + // Code: 0000...0001 + let data = hex::decode( + "4e487b71\ + 0000000000000000000000000000000000000000000000000000000000000001", + ) + .unwrap(); + + let decoded = decode_revert(&Bytes::from(data)); + + match decoded { + DecodedRevert::Panic(code) => { + assert_eq!(code, 1); + } + _ => panic!("Expected Panic, got {:?}", decoded), + } + } + + /// Test the Display implementation for all DecodedRevert variants. + #[test] + fn test_display() { + let err = DecodedRevert::ErrorString("test error".to_string()); + assert_eq!(format!("{}", err), "test error"); + + let panic = DecodedRevert::Panic(1); + assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); + } + + /// 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"; + let decoded = try_extract_from_string(error_msg); + assert!(decoded.is_some()); + if let Some(DecodedRevert::ErrorString(msg)) = decoded { + assert!(msg.contains("NilAV")); + } + + // Test with raw hex selector embedded in string + let error_msg2 = "some error 0x08c379a0abcdef"; + let decoded2 = try_extract_from_string(error_msg2); + assert!(decoded2.is_some()); + } + + /// Test that panic_reason returns correct descriptions for known codes. + #[test] + fn test_panic_reasons() { + assert_eq!(panic_reason(0x01), "assertion failed"); + assert_eq!(panic_reason(0x11), "arithmetic overflow/underflow"); + assert_eq!(panic_reason(0x12), "division by zero"); + } +} diff --git a/crates/erc-8004-contract-clients/src/common/event_helper.rs b/crates/erc-8004-contract-clients/src/common/event_helper.rs new file mode 100644 index 0000000..3e2e56a --- /dev/null +++ b/crates/erc-8004-contract-clients/src/common/event_helper.rs @@ -0,0 +1,188 @@ +//! # Event Helper +//! +//! This module provides common event listening and querying patterns to reduce boilerplate +//! across contract clients. +//! +//! ## Usage +//! +//! ```ignore +//! use crate::contract_client::event_helper::BlockRange; +//! +//! // Query with block range +//! let range = BlockRange::last_n_blocks(1000); +//! ``` + +use anyhow::Result; +use futures_util::StreamExt; +use tracing::error; + +/// Represents a block range for event queries. +/// +/// Provides convenient constructors for common query patterns. +#[derive(Debug, Clone, Copy)] +pub struct BlockRange { + pub from_block: u64, + pub to_block: Option, +} + +impl BlockRange { + /// Create a range from a specific block to the latest block. + pub fn from(from_block: u64) -> Self { + Self { + from_block, + to_block: None, + } + } + + /// Create a range between two specific blocks (inclusive). + pub fn between(from_block: u64, to_block: u64) -> Self { + Self { + from_block, + to_block: Some(to_block), + } + } + + /// Create a range for the last N blocks from the current block. + /// + /// Note: This requires knowing the current block number, so it returns + /// a function that takes the current block and returns the range. + pub fn from_lookback(current_block: u64, lookback_blocks: u64) -> Self { + Self { + from_block: current_block.saturating_sub(lookback_blocks), + to_block: None, + } + } + + /// Query the entire blockchain history. + pub fn all() -> Self { + Self { + from_block: 0, + to_block: None, + } + } +} + +impl Default for BlockRange { + fn default() -> Self { + Self::all() + } +} + +/// Listen to events with a filter predicate. +/// +/// This is the base event listener that reduces boilerplate by handling: +/// - Stream iteration +/// - Error logging for both event processing and reception +/// - Graceful handling of stream termination +/// - Optional filtering via predicate +/// +/// # Type Parameters +/// +/// * `E` - The event type (must be Send) +/// * `Err` - The error type from the stream +/// * `L` - The stream type +/// * `P` - The predicate function type +/// * `F` - The callback function type +/// * `Fut` - The future returned by the callback +/// +/// # Arguments +/// +/// * `stream` - The event stream to listen to +/// * `event_name` - Name of the event for logging purposes +/// * `predicate` - Function that returns true if the event should be processed +/// * `callback` - Async function to process each matching event +pub async fn listen_events_filtered( + mut stream: L, + event_name: &str, + predicate: P, + mut callback: F, +) -> Result<()> +where + E: Send, + Err: std::fmt::Display, + L: StreamExt> + Unpin + Send, + P: Fn(&E) -> bool + Send, + F: FnMut(E) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + while let Some(event_result) = stream.next().await { + match event_result { + Ok((event, _log)) => { + if predicate(&event) + && let Err(e) = callback(event).await + { + error!("Error processing {} event: {}", event_name, e); + } + } + Err(e) => { + error!("Error receiving {} event: {}", event_name, e); + } + } + } + Ok(()) +} + +/// Listen to events from a subscription and process them with a callback. +/// +/// This is a convenience wrapper around [`listen_events_filtered`] that processes +/// all events without filtering. +/// +/// # Arguments +/// +/// * `stream` - The event stream to listen to +/// * `event_name` - Name of the event for logging purposes +/// * `callback` - Async function to process each event +pub async fn listen_events( + stream: L, + event_name: &str, + callback: F, +) -> Result<()> +where + E: Send, + Err: std::fmt::Display, + L: StreamExt> + Unpin + Send, + F: FnMut(E) -> Fut + Send, + Fut: std::future::Future> + Send, +{ + listen_events_filtered(stream, event_name, |_| true, callback).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_block_range_from() { + let range = BlockRange::from(100); + assert_eq!(range.from_block, 100); + assert_eq!(range.to_block, None); + } + + #[test] + fn test_block_range_between() { + let range = BlockRange::between(100, 200); + assert_eq!(range.from_block, 100); + assert_eq!(range.to_block, Some(200)); + } + + #[test] + fn test_block_range_lookback() { + let range = BlockRange::from_lookback(1000, 100); + assert_eq!(range.from_block, 900); + assert_eq!(range.to_block, None); + } + + #[test] + fn test_block_range_lookback_underflow() { + let range = BlockRange::from_lookback(50, 100); + assert_eq!(range.from_block, 0); // saturating_sub prevents underflow + assert_eq!(range.to_block, None); + } + + #[test] + fn test_block_range_all() { + let range = BlockRange::all(); + assert_eq!(range.from_block, 0); + assert_eq!(range.to_block, None); + } +} diff --git a/crates/erc-8004-contract-clients/src/common/mod.rs b/crates/erc-8004-contract-clients/src/common/mod.rs new file mode 100644 index 0000000..a8001f0 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/common/mod.rs @@ -0,0 +1,22 @@ +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<&P, D>, +) -> 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/erc-8004-contract-clients/src/common/tx_submitter.rs b/crates/erc-8004-contract-clients/src/common/tx_submitter.rs new file mode 100644 index 0000000..08dc3fd --- /dev/null +++ b/crates/erc-8004-contract-clients/src/common/tx_submitter.rs @@ -0,0 +1,160 @@ +use alloy::{ + consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, + rpc::types::TransactionReceipt, sol_types::SolInterface, +}; +use anyhow::{Result, anyhow}; +use std::fmt::Debug; +use std::marker::PhantomData; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{info, warn}; + +#[derive(Clone)] +pub(crate) struct TransactionSubmitter { + tx_lock: Arc>, + gas_limit: Option, + _decoder: PhantomData, +} + +impl TransactionSubmitter { + pub(crate) fn new(tx_lock: Arc>) -> Self { + Self { + tx_lock, + gas_limit: None, + _decoder: PhantomData, + } + } + + pub(crate) async fn invoke(&self, method: &str, call: CallBuilder) -> Result + where + P: Provider + Clone, + D: alloy::contract::CallDecoder + Clone, + { + // 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}")); + } + + let call = match self.gas_limit { + Some(gas) => call.gas(gas), + None => call, + }; + + let provider = call.provider.clone(); + let estimate = provider.estimate_eip1559_fees().await?; + + // Our L2 requires a minimum priority fee of 1 wei + let priority_fee = 1u128; + let call = call + .max_priority_fee_per_gas(priority_fee) + .max_fee_per_gas(estimate.max_fee_per_gas); + + let estimated_gas = call.clone().estimate_gas().await?; + + // 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}") + })?; + + // Wait for receipt + let receipt = pending.get_receipt().await?; + let tx_hash = receipt.transaction_hash; + + Self::log_fee_details( + &provider, + method, + tx_hash, + &receipt, + estimated_gas, + estimate.max_priority_fee_per_gas, + ) + .await; + + // Validate success + if !receipt.status() { + if let Some(limit) = self.gas_limit { + let used = receipt.gas_used; + if used >= limit { + return Err(anyhow!( + "{method} ran out of gas (used {used} of {limit} limit). Tx: {tx_hash:?}" + )); + } + } + + return Err(anyhow!("{method} reverted on-chain. Tx hash: {tx_hash:?}")); + } + + Ok(tx_hash) + } + + fn decode_error(&self, error: alloy::contract::Error) -> String { + match error.try_decode_into_interface_error::() { + Ok(error) => format!("{error:?}"), + Err(error) => super::errors::decode_any_error(&error).to_string(), + } + } + + async fn log_fee_details( + provider: &P, + method: &str, + tx_hash: B256, + receipt: &TransactionReceipt, + estimated_gas: u64, + estimated_priority_fee: u128, + ) { + // Fetch actual transaction to get the real fee parameters + let (tx_max_fee, tx_max_priority_fee) = + match provider.get_transaction_by_hash(tx_hash).await { + Ok(Some(tx)) => (Some(tx.max_fee_per_gas()), tx.max_priority_fee_per_gas()), + _ => (None, None), + }; + + // Calculate actual priority fee paid: effective_gas_price - base_fee + let actual_priority_fee = if let Some(block_num) = receipt.block_number { + provider + .get_block_by_number(block_num.into()) + .await + .ok() + .flatten() + .and_then(|b| b.header.base_fee_per_gas) + .map(|base_fee| receipt.effective_gas_price.saturating_sub(base_fee as u128)) + } else { + None + }; + + let total_cost = receipt.effective_gas_price * receipt.gas_used as u128; + let actual_priority_fee = actual_priority_fee.unwrap_or(0); + if actual_priority_fee < estimated_priority_fee.saturating_sub(1_000_000_000u128) { + warn!( + method = %method, + tx_hash = ?tx_hash, + effective_gas_price = receipt.effective_gas_price, + gas_used = receipt.gas_used, + estimated_gas = ?estimated_gas, + total_cost, + tx_max_fee = ?tx_max_fee, + tx_max_priority_fee = ?tx_max_priority_fee, + actual_priority_fee = ?actual_priority_fee, + estimated_priority_fee = ?estimated_priority_fee, + "💰 transaction gas details (priority fee may be too low)" + ); + } else { + info!( + method = %method, + tx_hash = ?tx_hash, + effective_gas_price = receipt.effective_gas_price, + gas_used = receipt.gas_used, + estimated_gas = ?estimated_gas, + total_cost, + tx_max_fee = ?tx_max_fee, + tx_max_priority_fee = ?tx_max_priority_fee, + actual_priority_fee = ?actual_priority_fee, + estimated_priority_fee = ?estimated_priority_fee, + "💰 transaction gas details" + ); + } + } +} 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..2e9c915 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/erc_8004_client.rs @@ -0,0 +1,97 @@ +use crate::{ContractConfig, IdentityRegistryClient, ValidationRegistryClient}; +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; + +/// High-level wrapper bundling ERC-8004 contract clients with a shared Alloy provider. +#[derive(Clone)] +pub struct Erc8004Client { + provider: DynProvider, + wallet: EthereumWallet, + pub identity_registry: IdentityRegistryClient, + pub validation_registry: ValidationRegistryClient, +} + +impl Erc8004Client { + 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 ws = WsConnect::new(ws_url); + 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(); + + let tx_lock = Arc::new(Mutex::new(())); + + // 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.clone(), + ); + + Ok(Self { + provider, + wallet, + identity_registry, + validation_registry, + }) + } + + /// Get the signer address + pub fn signer_address(&self) -> Address { + >::default_signer_address(&self.wallet) + } + + /// 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?) + } + + /// 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?) + } + + /// 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) + } + + /// 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/erc-8004-contract-clients/src/identity_registry.rs b/crates/erc-8004-contract-clients/src/identity_registry.rs new file mode 100644 index 0000000..edc5130 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/identity_registry.rs @@ -0,0 +1,121 @@ +use crate::common::tx_submitter::TransactionSubmitter; +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +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..df5403b --- /dev/null +++ b/crates/erc-8004-contract-clients/src/lib.rs @@ -0,0 +1,125 @@ +use alloy::primitives::Address; + +pub mod common; +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..58c4149 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -0,0 +1,85 @@ +use crate::common::tx_submitter::TransactionSubmitter; +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +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; + } +} + +use ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; + +/// 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() + } + + /// Rust stub for the Solidity `requestValidation` semantics (no snapshotId). + pub async fn request_validation( + &self, + validator_address: Address, + agent_id: U256, + request_uri: String, + request_hash: B256, + ) -> Result { + let call = self.contract.validationRequest( + validator_address, + agent_id, + request_uri, + request_hash, + 0, + ); + self.submitter.invoke("validationRequest", call).await + } + + /// 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 + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 4c49bc8..eff4e64 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: # Anvil - Local Ethereum testnet anvil: - image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-64cd680 + image: nilanvil:latest container_name: blacklight-anvil ports: - "8545:8545" @@ -15,6 +15,27 @@ 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_HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - L2_JAILING_POLICY_ADDRESS=0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 + - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F + # NilCC Simulator - Submits HTXs to the contract simulator: build: @@ -32,7 +53,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 +71,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 + - HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - AGENT_URI=https://api.nilai.nillion.network/v1/health/ - RUST_LOG=info networks: - blacklight-network @@ -84,10 +127,11 @@ services: 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 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..c3d991f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -58,6 +58,7 @@ RUN mkdir -p ~/.cargo && \ COPY Cargo.toml ./ COPY crates ./crates COPY nilcc-simulator ./nilcc-simulator +COPY erc-8004-simulator ./erc-8004-simulator COPY keeper ./keeper COPY blacklight-node ./blacklight-node COPY monitor ./monitor @@ -85,6 +86,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ 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/erc-8004-simulator /out/bin/ && \ cp target/$RUST_TARGET/release/monitor /out/bin/ && \ cp target/$RUST_TARGET/release/keeper /out/bin/ @@ -119,6 +121,11 @@ COPY --from=builder /app/data /app/data ENV HTXS_PATH=/app/data/htxs.json ENTRYPOINT ["/usr/local/bin/nilcc-simulator"] +# Runtime stage for erc_8004_simulator +FROM base_release AS erc_8004_simulator +COPY --from=builder /out/bin/erc-8004-simulator /usr/local/bin/erc-8004-simulator +ENTRYPOINT ["/usr/local/bin/erc-8004-simulator"] + # Runtime stage for monitor FROM base_release AS monitor COPY --from=builder /out/bin/monitor /usr/local/bin/monitor diff --git a/erc-8004-simulator/Cargo.toml b/erc-8004-simulator/Cargo.toml new file mode 100644 index 0000000..b00ff7a --- /dev/null +++ b/erc-8004-simulator/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "erc-8004-simulator" +version = "0.1.0" +edition = "2024" + +[dependencies] +alloy = { version = "1.1", features = ["contract", "providers"] } +anyhow = "1.0" +clap = { version = "4.5", features = ["derive", "env"] } +tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } +chain-args = { path = "../crates/chain-args" } +state-file = { path = "../crates/state-file" } diff --git a/erc-8004-simulator/src/args.rs b/erc-8004-simulator/src/args.rs new file mode 100644 index 0000000..30c302a --- /dev/null +++ b/erc-8004-simulator/src/args.rs @@ -0,0 +1,119 @@ +use alloy::primitives::Address; +use anyhow::Result; +use clap::Parser; + +use state_file::StateFile; +use tracing::info; + +const STATE_FILE_SIMULATOR: &str = "erc_8004_simulator.env"; + +/// Default slot interval in milliseconds - how often simulator submits validation requests +#[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 ERC-8004 simulator +#[derive(Parser, Debug)] +#[command(name = "erc_8004_simulator")] +#[command(about = "ERC-8004 Simulator - Registers agents and submits validation requests", long_about = None)] +pub struct CliArgs { + /// 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, + + /// HeartbeatManager contract address to submit validation requests to + #[arg(long, env = "HEARTBEAT_MANAGER_ADDRESS")] + pub heartbeat_manager_address: Option, +} + +/// Simulator configuration with all required values resolved +#[derive(Debug, Clone)] +pub struct SimulatorConfig { + pub rpc_url: String, + pub identity_registry_contract_address: Address, + pub validation_registry_contract_address: Address, + pub private_key: String, + pub agent_uri: String, + pub heartbeat_manager_address: Address, + 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); + + // Load RPC URL with priority + let rpc_url = cli_args + .rpc_url + .or_else(|| state_file.load_value("RPC_URL")) + .unwrap_or_else(|| "http://127.0.0.1:8545".to_string()); + + // Load IdentityRegistry contract address + let identity_registry_contract_address = cli_args + .identity_registry_contract_address + .or_else(|| state_file.load_value("IDENTITY_REGISTRY_CONTRACT_ADDRESS")) + .unwrap_or_else(|| "0x5FbDB2315678afecb367f032d93F642f64180aa3".to_string()) + .parse::

()?; + + // Load ValidationRegistry contract address + let validation_registry_contract_address = cli_args + .validation_registry_contract_address + .or_else(|| state_file.load_value("VALIDATION_REGISTRY_CONTRACT_ADDRESS")) + .unwrap_or_else(|| "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".to_string()) + .parse::
()?; + + // Load private key with priority (Anvil account #3 as default) + let private_key = cli_args + .private_key + .or_else(|| state_file.load_value("PRIVATE_KEY")) + .unwrap_or_else(|| { + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() + }); + + // Load agent URI + let agent_uri = cli_args + .agent_uri + .or_else(|| state_file.load_value("AGENT_URI")) + .unwrap_or_else(|| "https://example.com/agent".to_string()); + + // Load HeartbeatManager contract address + let heartbeat_manager_address = cli_args + .heartbeat_manager_address + .or_else(|| state_file.load_value("HEARTBEAT_MANAGER_ADDRESS")) + .unwrap_or_else(|| "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707".to_string()) + .parse::
()?; + + info!( + "Loaded SimulatorConfig: rpc_url={rpc_url}, identity_registry={identity_registry_contract_address}, validation_registry={validation_registry_contract_address}" + ); + + Ok(SimulatorConfig { + rpc_url, + identity_registry_contract_address, + validation_registry_contract_address, + private_key, + agent_uri, + heartbeat_manager_address, + slot_ms: DEFAULT_SLOT_MS, + }) + } +} diff --git a/erc-8004-simulator/src/main.rs b/erc-8004-simulator/src/main.rs new file mode 100644 index 0000000..dad9d33 --- /dev/null +++ b/erc-8004-simulator/src/main.rs @@ -0,0 +1,180 @@ +use alloy::primitives::{B256, U256, keccak256}; +use anyhow::Result; +use args::{CliArgs, SimulatorConfig}; +use clap::Parser; +use erc_8004_contract_clients::{ContractConfig, Erc8004Client}; +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?; + + // Register the agent first + let agent_id = register_agent(&client, &config).await?; + + // Run the validation request submission loop + run_submission_loop(client, config, agent_id).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.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: &SimulatorConfig) -> 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"); + + // Verify registration by querying the agent + 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) +} + +async fn run_submission_loop( + client: Erc8004Client, + config: SimulatorConfig, + agent_id: U256, +) -> Result<()> { + let mut ticker = interval(Duration::from_millis(config.slot_ms)); + let mut slot = 0u64; + let client = Arc::new(client); + let config = Arc::new(config); + + 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 config = Arc::clone(&config); + tokio::spawn(async move { + if let Err(e) = submit_validation_request(&client, &config, agent_id, slot).await { + error!(slot, error = %e, "Validation request submission failed"); + } + }); + } +} + +const MAX_RETRIES: u32 = 3; +const RETRY_DELAY_MS: u64 = 500; + +async fn submit_validation_request( + client: &Arc, + config: &Arc, + agent_id: U256, + slot: u64, +) -> Result<()> { + let mut last_error = None; + + for attempt in 0..MAX_RETRIES { + // Get current block number for snapshot ID (use block - 1 for committee selection) + let block_number = client.get_block_number().await?; + let snapshot_id = block_number.saturating_sub(1); + + // Use same URI but include snapshot_id in hash to make each request unique + 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, + heartbeat_manager = %config.heartbeat_manager_address, + snapshot_id = snapshot_id, + request_uri = %request_uri, + "Submitting validation request" + ); + } else { + info!(slot, attempt, "Retrying validation request submission"); + } + + match client + .validation_registry + .validation_request( + config.heartbeat_manager_address, + agent_id, + request_uri.clone(), + request_hash, + snapshot_id, + ) + .await + { + Ok(tx_hash) => { + info!(slot, tx_hash = ?tx_hash, "Validation request 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/src/main.rs b/nilcc-simulator/src/main.rs index 4a125dd..fc38287 100644 --- a/nilcc-simulator/src/main.rs +++ b/nilcc-simulator/src/main.rs @@ -1,7 +1,7 @@ use anyhow::Result; use args::{CliArgs, SimulatorConfig}; use blacklight_contract_clients::{ - htx::{Htx, NillionHtx, PhalaHtx}, + htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, {BlacklightClient, ContractConfig}, }; use clap::Parser; @@ -60,7 +60,8 @@ async fn setup_client(config: &SimulatorConfig) -> Result { 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(); + 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"); @@ -129,6 +130,10 @@ async fn submit_next_htx( Htx::Phala(PhalaHtx::V1(htx)) => { htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); } + Htx::Erc8004(_) => { + // ERC-8004 HTXs are not loaded from JSON files, skip + unreachable!("ERC-8004 HTXs should not be loaded from JSON files"); + } } htx }; From 9a59f7be03612623962b4d129d85d88d2f300b4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Mon, 2 Feb 2026 14:56:37 +0100 Subject: [PATCH 02/17] refactor: extract shared contract client utilities into common crate --- Cargo.toml | 1 + crates/blacklight-contract-clients/Cargo.toml | 1 + .../src/common/errors.rs | 604 ++---------------- .../src/common/mod.rs | 20 +- crates/contract-clients-common/Cargo.toml | 11 + .../src}/errors.rs | 177 +++-- .../src}/event_helper.rs | 2 +- crates/contract-clients-common/src/lib.rs | 54 ++ .../src}/tx_submitter.rs | 58 +- crates/erc-8004-contract-clients/Cargo.toml | 1 + .../src/common/event_helper.rs | 188 ------ .../src/common/mod.rs | 29 +- .../src/common/tx_submitter.rs | 160 ----- docker-compose.yml | 2 +- 14 files changed, 332 insertions(+), 976 deletions(-) create mode 100644 crates/contract-clients-common/Cargo.toml rename crates/{erc-8004-contract-clients/src/common => contract-clients-common/src}/errors.rs (79%) rename crates/{blacklight-contract-clients/src/common => contract-clients-common/src}/event_helper.rs (98%) create mode 100644 crates/contract-clients-common/src/lib.rs rename crates/{blacklight-contract-clients/src/common => contract-clients-common/src}/tx_submitter.rs (73%) delete mode 100644 crates/erc-8004-contract-clients/src/common/event_helper.rs delete mode 100644 crates/erc-8004-contract-clients/src/common/tx_submitter.rs diff --git a/Cargo.toml b/Cargo.toml index 83a76cb..426abeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "blacklight-node", "crates/blacklight-contract-clients", "crates/chain-args", + "crates/contract-clients-common", "crates/erc-8004-contract-clients", "crates/state-file", "erc-8004-simulator", diff --git a/crates/blacklight-contract-clients/Cargo.toml b/crates/blacklight-contract-clients/Cargo.toml index 3091f07..2f09b0b 100644 --- a/crates/blacklight-contract-clients/Cargo.toml +++ b/crates/blacklight-contract-clients/Cargo.toml @@ -7,6 +7,7 @@ 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" diff --git a/crates/blacklight-contract-clients/src/common/errors.rs b/crates/blacklight-contract-clients/src/common/errors.rs index 64488d5..4f3ffa8 100644 --- a/crates/blacklight-contract-clients/src/common/errors.rs +++ b/crates/blacklight-contract-clients/src/common/errors.rs @@ -1,264 +1,41 @@ -//! # Error Handling for Solidity Revert Data +//! # Blacklight Error Handling //! -//! This module provides type-safe decoding of Solidity revert data using the Alloy library. -//! When a smart contract transaction fails (reverts), the EVM returns encoded error data. -//! This module decodes that raw hex data into human-readable error messages. +//! This module extends `contract-clients-common` error handling with +//! StakingOperators-specific custom error decoding. //! -//! ## Supported Error Types +//! ## Supported Custom Errors //! -//! 1. **Standard `Error(string)`** - From `require(condition, "message")` statements -//! - Selector: `0x08c379a0` -//! - Most common error type in Solidity contracts +//! - `StakingOperatorsErrors` - Custom errors from the StakingOperators contract //! -//! 2. **Standard `Panic(uint256)`** - From `assert()` failures and arithmetic errors -//! - Selector: `0x4e487b71` -//! - Includes overflow, division by zero, array bounds, etc. +//! ## Usage //! -//! 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 -//! - Each error has a unique 4-byte selector derived from its signature +//! ```ignore +//! use blacklight_contract_clients::common::errors::decode_any_error; //! -//! ## 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 +//! let decoded = decode_any_error(&some_error); +//! println!("Error: {}", decoded); //! ``` -//! -//! ## Main Entry Points -//! -//! - [`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 - -use alloy::{ - contract::Error as ContractError, hex, primitives::Bytes, sol, sol_types::SolInterface, - transports::TransportError, -}; - -// ============================================================================ -// Standard Solidity Errors -// ============================================================================ -// -// The `sol!` macro generates Rust types that can decode ABI-encoded error data. -// Each error has a unique 4-byte "selector" (first 4 bytes of keccak256(signature)) -// that identifies it in the raw revert data. -sol! { - /// Standard Solidity errors used by the EVM. - /// - /// - `Error(string)` - Produced by `require(condition, "message")` when condition is false - /// Selector: `0x08c379a0` = keccak256("Error(string)")[:4] - /// - /// - `Panic(uint256)` - Produced by `assert()` failures, arithmetic errors, etc. - /// Selector: `0x4e487b71` = keccak256("Panic(uint256)")[:4] - #[derive(Debug, PartialEq, Eq)] - library StandardErrors { - error Error(string message); - error Panic(uint256 code); - } -} +use alloy::{primitives::Bytes, sol_types::SolInterface}; -// ============================================================================ -// 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 the base types and functions from the shared crate +pub use contract_clients_common::errors::{ + DecodedRevert, decode_revert, decode_revert_with_custom, extract_revert_from_contract_error, + panic_reason, try_extract_from_string, +}; /// 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 -// ============================================================================ - -/// Represents the result of decoding Solidity revert data. -/// -/// This enum captures all possible outcomes when attempting to decode raw revert bytes: -/// -/// | Variant | When it's used | -/// |---------|----------------| -/// | `ErrorString` | `require()` failed with a message | -/// | `Panic` | `assert()` failed or arithmetic error | -/// | `StakingError` | Custom error from StakingOperators contract | -/// | `RawRevert` | We got hex data but couldn't decode it | -/// | `NoRevertData` | No revert data at all (unusual) | -#[derive(Debug, Clone)] -pub enum DecodedRevert { - /// Standard `Error(string)` from `require(condition, "message")` statements. - /// This is the most common error type in Solidity contracts. - ErrorString(String), - - /// Panic error with a numeric panic code. - /// Produced by `assert()` failures, arithmetic overflow, division by zero, etc. - /// 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), - - /// Raw revert data that couldn't be decoded by any known error type. - /// Contains the hex bytes so the user can manually debug. - RawRevert(Bytes), - - /// No revert data was available in the error. - /// This is unexpected for contract reverts - includes context about why. - NoRevertData(String), -} - -impl std::fmt::Display for DecodedRevert { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - 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::RawRevert(data) => write!(f, "Raw revert data: {}", data), - DecodedRevert::NoRevertData(details) => write!(f, "No revert data ({})", details), - } - } -} - -// ============================================================================ -// Panic Code Meanings -// ============================================================================ - -/// Get human-readable reason for Solidity panic codes. -/// -/// Panic codes are defined in the Solidity documentation: -/// -/// -/// # Panic Codes -/// -/// | Code | Meaning | -/// |------|---------| -/// | 0x00 | Generic compiler panic | -/// | 0x01 | `assert()` failure | -/// | 0x11 | Arithmetic overflow/underflow | -/// | 0x12 | Division by zero | -/// | 0x21 | Invalid enum value | -/// | 0x22 | Storage byte array encoding error | -/// | 0x31 | `pop()` on empty array | -/// | 0x32 | Array index out of bounds | -/// | 0x41 | Memory allocation overflow | -/// | 0x51 | Zero-initialized function pointer call | -fn panic_reason(code: u64) -> &'static str { - match code { - 0x00 => "generic compiler panic", - 0x01 => "assertion failed", - 0x11 => "arithmetic overflow/underflow", - 0x12 => "division by zero", - 0x21 => "invalid enum value", - 0x22 => "storage byte array encoding error", - 0x31 => "pop on empty array", - 0x32 => "array index out of bounds", - 0x41 => "memory allocation overflow", - 0x51 => "zero-initialized function pointer call", - _ => "unknown panic code", - } -} - -// ============================================================================ -// Core Decoding Logic -// ============================================================================ - -/// Decode raw revert data bytes into a human-readable error. -/// -/// 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 -/// 4. **Fallback** - Return the raw hex so user can debug -/// -/// # Arguments -/// -/// * `data` - Raw ABI-encoded revert data from the EVM -/// -/// # Returns -/// -/// A [`DecodedRevert`] variant representing the decoded error. -/// -/// # Example -/// -/// ```ignore -/// let revert_data = Bytes::from(hex::decode("08c379a0...").unwrap()); -/// let decoded = decode_revert(&revert_data); -/// println!("Error: {}", decoded); // "blacklight: HTX already exists" -/// ``` -pub fn decode_revert(data: &Bytes) -> DecodedRevert { - // Empty revert data is unusual - contracts normally include some data - if data.is_empty() { - return DecodedRevert::NoRevertData("empty revert data".to_string()); - } - - // Step 1: Try to decode as standard Error(string) or Panic(uint256) - // The abi_decode method checks the 4-byte selector and decodes the rest - if let Ok(err) = StandardErrors::StandardErrorsErrors::abi_decode(data) { - match err { - StandardErrors::StandardErrorsErrors::Error(e) => { - return DecodedRevert::ErrorString(e.message); - } - StandardErrors::StandardErrorsErrors::Panic(p) => { - // Panic code is a uint256, but we only care about the low bits - return DecodedRevert::Panic(p.code.try_into().unwrap_or(0)); - } - } - } - - // 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 3: Unknown error - return raw bytes so user can debug - // This allows users to manually decode or report the unknown error type - DecodedRevert::RawRevert(data.clone()) -} - -// ============================================================================ -// Human-Readable Error Formatting +// StakingOperators 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(), @@ -276,204 +53,26 @@ fn format_staking_error(err: &StakingOperatorsErrors) -> String { } } -// ============================================================================ -// Alloy ContractError Extraction -// ============================================================================ -// -// Alloy wraps errors in ContractError, which contains TransportError for RPC errors. -// The revert data is often buried in the TransportError's ErrorResp data field. -// This section extracts that data properly instead of relying on string parsing. - -/// Extract and decode revert data from an Alloy [`ContractError`]. -/// -/// This is the **proper** way to get revert data from Alloy errors - it accesses -/// the structured error fields directly rather than parsing strings. -/// -/// # How It Works -/// -/// 1. For `TransportError`: Extracts hex data from the RPC error response -/// 2. For `AbiError`: Returns the ABI encoding error (rare) -/// 3. For other types: Falls back to parsing the Debug representation -/// -/// # Arguments -/// -/// * `error` - The Alloy contract error to extract revert data from -/// -/// # Returns -/// -/// A [`DecodedRevert`] with the decoded error or context about why decoding failed. -pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRevert { - match error { - // TransportError is the most common - it wraps RPC error responses - ContractError::TransportError(transport_err) => { - extract_revert_from_transport_error(transport_err) - } - // ABI errors happen when encoding/decoding fails (rare) - ContractError::AbiError(abi_err) => { - DecodedRevert::NoRevertData(format!("ABI error: {}", abi_err)) - } - // For other error types, try to extract from the Debug representation - // 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) { - decoded - } else { - DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) - } - } - } -} - -/// Extract revert data from an Alloy [`TransportError`]. -/// -/// The RPC response for a reverted transaction typically includes: -/// - `ErrorResp.data`: Hex-encoded revert data (what we want) -/// - `ErrorResp.message`: Human-readable error message from the RPC node -/// -/// # Arguments -/// -/// * `error` - The transport error from the RPC layer -/// -/// # Returns -/// -/// A [`DecodedRevert`] with the decoded error data. -fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert { - match error { - TransportError::ErrorResp(err_resp) => { - // The error response may contain revert data in the `data` field - if let Some(data) = &err_resp.data { - // Get the raw JSON value as a string - let data_str = data.get(); - // Remove JSON quotes if present (RPC returns "0x..." as a JSON string) - let data_str = data_str.trim_matches('"'); - - // Try to parse as hex string starting with 0x - if let Some(hex_data) = data_str.strip_prefix("0x") - && let Ok(bytes) = hex::decode(hex_data) - { - return decode_revert(&Bytes::from(bytes)); - } - // If not hex, include the raw data for debugging - return DecodedRevert::NoRevertData(format!("Error data: {}", data_str)); - } - // No data field, but we have the RPC error message - DecodedRevert::NoRevertData(format!("RPC error: {}", err_resp.message)) - } - // 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) { - decoded - } else { - DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) - } - } - } -} - -// ============================================================================ -// String Pattern Extraction (Fallback) -// ============================================================================ -// -// Different RPC providers format error messages differently. This fallback -// searches the error string for hex patterns that might contain revert data. -// This is less reliable than structured extraction but catches edge cases. - -/// Try to extract revert data from an error string (fallback mechanism). -/// -/// Different RPC providers format errors differently: -/// - `execution reverted: 0x08c379a0...` -/// - `reverted with data: 0x...` -/// - `data: 0x...` -/// -/// This function searches for these patterns and extracts the hex data. -/// -/// # Arguments +/// Custom error decoder for StakingOperators errors. /// -/// * `error_str` - The error message string to search -/// -/// # Returns -/// -/// `Some(DecodedRevert)` if hex data was found and decoded, `None` otherwise. -fn try_extract_from_string(error_str: &str) -> Option { - // Patterns that indicate hex revert data follows - const PATTERNS: &[&str] = &[ - "execution reverted: 0x", - "reverted with data: 0x", - "revert data: 0x", - "data: 0x", - "0x08c379a0", // Error(string) selector - direct match - "0x4e487b71", // Panic(uint256) selector - direct match - ]; - - for pattern in PATTERNS { - if let Some(start) = error_str.find(pattern) { - // Calculate where the hex data starts - let hex_start = if pattern.ends_with("0x") { - start + pattern.len() - 2 // Include the 0x prefix - } else { - start // Pattern IS the start of hex data - }; - - let remaining = &error_str[hex_start..]; - - // Find the end of the hex string (only hex chars after 0x) - let hex_end = if remaining.starts_with("0x") { - 2 + remaining - .strip_prefix("0x") - .unwrap_or(remaining) - .chars() - .take_while(|c| c.is_ascii_hexdigit()) - .count() - } else { - remaining - .chars() - .take_while(|c| c.is_ascii_hexdigit()) - .count() - }; - - let hex_str = &remaining[..hex_end]; - // Need at least 0x + 4 bytes (8 hex chars) for a valid selector - 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))); - } - } - } - } - - // Special case: plain text error message after "execution reverted:" - // Some RPC providers return: `execution reverted: blacklight: HTX already exists` - if error_str.contains("execution reverted") - && let Some(idx) = error_str.find("execution reverted:") - { - let after = &error_str[idx + 19..]; // Skip "execution reverted:" - let msg = after.trim().trim_matches('"').trim(); - // Only use if it's not hex data (already handled above) - if !msg.is_empty() && !msg.starts_with("0x") { - return Some(DecodedRevert::ErrorString(msg.to_string())); - } +/// This function attempts to decode raw bytes as a StakingOperators custom error. +fn decode_staking_error(data: &Bytes) -> Option { + if let Ok(err) = StakingOperatorsErrors::abi_decode(data) { + let msg = format_staking_error(&err); + Some(DecodedRevert::CustomError(msg)) + } else { + None } - - None } // ============================================================================ -// Generic Entry Points +// Blacklight-specific Entry Points // ============================================================================ -/// Decode ANY error into a human-readable message. +/// Decode ANY error into a human-readable message with StakingOperators support. /// -/// This is the **main entry point** for error decoding. It accepts any error type -/// that implements `Display` and `Debug`, and tries its best to extract revert data. -/// -/// # Strategy -/// -/// 1. Try extracting from the `Display` string representation -/// 2. Try extracting from the `Debug` string representation (often has more details) -/// 3. If nothing works, return the raw error so the user can see what's happening +/// This is the **main entry point** for error decoding in blacklight contracts. +/// It extends the base `decode_any_error` with StakingOperators custom error support. /// /// # Arguments /// @@ -482,32 +81,16 @@ fn try_extract_from_string(error_str: &str) -> Option { /// # Returns /// /// A [`DecodedRevert`] - never panics, always returns something useful. -/// -/// # Example -/// -/// ```ignore -/// let decoded = decode_any_error(&some_error); -/// log::error!("Transaction failed: {}", decoded); -/// ``` pub fn decode_any_error(error: &E) -> DecodedRevert { - 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) { - return decoded; - } - - // Then try the Debug representation (often has more details like struct fields) - if let Some(decoded) = try_extract_from_string(&debug_str) { - return decoded; - } + contract_clients_common::errors::decode_any_error_with_custom(error, decode_staking_error) +} - // If nothing works, return the error as-is so user can see what's happening - DecodedRevert::NoRevertData(format!( - "Could not extract revert data. Raw error: {}", - error_str - )) +/// Decode raw revert data bytes with StakingOperators error support. +/// +/// This function attempts to decode the raw bytes, including StakingOperators +/// custom errors. +pub fn decode_revert_blacklight(data: &Bytes) -> DecodedRevert { + decode_revert_with_custom(data, decode_staking_error) } // ============================================================================ @@ -517,64 +100,7 @@ pub fn decode_any_error(error: &E) -> De #[cfg(test)] mod tests { use super::*; - - /// Test decoding a standard Error(string) from require() statements. - /// - /// ABI encoding format for Error(string): - /// - Bytes 0-3: Selector (0x08c379a0) - /// - Bytes 4-35: Offset to string data (always 0x20 = 32) - /// - Bytes 36-67: String length - /// - Bytes 68+: UTF-8 string data (padded to 32 bytes) - #[test] - fn test_decode_error_string() { - // "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 - let data = hex::decode( - "08c379a0\ - 0000000000000000000000000000000000000000000000000000000000000020\ - 0000000000000000000000000000000000000000000000000000000000000012\ - 4e696c41563a20756e6b6e6f776e204854580000000000000000000000000000", - ) - .unwrap(); - - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::ErrorString(msg) => { - assert_eq!(msg, "NilAV: unknown HTX"); - } - _ => panic!("Expected ErrorString, got {:?}", decoded), - } - } - - /// Test decoding a Panic(uint256) from assert() failures. - /// - /// ABI encoding format for Panic(uint256): - /// - Bytes 0-3: Selector (0x4e487b71) - /// - Bytes 4-35: Panic code as uint256 - #[test] - fn test_decode_panic() { - // Panic(1) - assert failure - // Selector: 4e487b71 - // Code: 0000...0001 - let data = hex::decode( - "4e487b71\ - 0000000000000000000000000000000000000000000000000000000000000001", - ) - .unwrap(); - - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::Panic(code) => { - assert_eq!(code, 1); - } - _ => panic!("Expected Panic, got {:?}", decoded), - } - } + use alloy::hex; /// Test decoding a custom StakingOperators error. /// @@ -585,51 +111,35 @@ mod tests { // InsufficientStake() error - selector only, no params let data = hex::decode("f1bc94d2").unwrap(); - let decoded = decode_revert(&Bytes::from(data)); + let decoded = decode_revert_blacklight(&Bytes::from(data)); match decoded { - DecodedRevert::StakingError(msg) => { + DecodedRevert::CustomError(msg) => { assert_eq!(msg, "Insufficient stake"); } - _ => panic!("Expected StakingError, got {:?}", decoded), + _ => panic!("Expected CustomError, got {:?}", decoded), } } - /// Test the Display implementation for all DecodedRevert variants. + /// Test that standard errors still work. #[test] - fn test_display() { - let err = DecodedRevert::ErrorString("test error".to_string()); - assert_eq!(format!("{}", err), "test error"); - - let panic = DecodedRevert::Panic(1); - assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); + fn test_decode_standard_error() { + // "test" encoded as Error(string) + let data = hex::decode( + "08c379a0\ + 0000000000000000000000000000000000000000000000000000000000000020\ + 0000000000000000000000000000000000000000000000000000000000000004\ + 7465737400000000000000000000000000000000000000000000000000000000", + ) + .unwrap(); - let staking = DecodedRevert::StakingError("No stake".to_string()); - assert_eq!(format!("{}", staking), "No stake"); - } + let decoded = decode_revert_blacklight(&Bytes::from(data)); - /// 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"; - let decoded = try_extract_from_string(error_msg); - assert!(decoded.is_some()); - if let Some(DecodedRevert::ErrorString(msg)) = decoded { - assert!(msg.contains("NilAV")); + match decoded { + DecodedRevert::ErrorString(msg) => { + assert_eq!(msg, "test"); + } + _ => panic!("Expected ErrorString, got {:?}", decoded), } - - // Test with raw hex selector embedded in string - let error_msg2 = "some error 0x08c379a0abcdef"; - let decoded2 = try_extract_from_string(error_msg2); - assert!(decoded2.is_some()); - } - - /// Test that panic_reason returns correct descriptions for known codes. - #[test] - fn test_panic_reasons() { - assert_eq!(panic_reason(0x01), "assertion failed"); - assert_eq!(panic_reason(0x11), "arithmetic overflow/underflow"); - assert_eq!(panic_reason(0x12), "division by zero"); } } diff --git a/crates/blacklight-contract-clients/src/common/mod.rs b/crates/blacklight-contract-clients/src/common/mod.rs index 8d581c0..83abf2c 100644 --- a/crates/blacklight-contract-clients/src/common/mod.rs +++ b/crates/blacklight-contract-clients/src/common/mod.rs @@ -1,14 +1,26 @@ -use crate::common::errors::decode_any_error; +//! Common utilities for blacklight contract clients. +//! +//! This module re-exports shared utilities from `contract-clients-common` +//! and provides blacklight-specific extensions for error decoding. + +// Re-export shared modules +pub use contract_clients_common::event_helper; +pub use contract_clients_common::tx_submitter; + +// Provide blacklight-specific errors module with StakingOperators error support +pub mod errors; + use alloy::{ contract::{CallBuilder, CallDecoder}, providers::Provider, }; use anyhow::anyhow; -pub mod errors; -pub mod event_helper; -pub mod tx_submitter; +use crate::common::errors::decode_any_error; +/// Estimate gas for a contract call with a 50% buffer. +/// +/// Uses blacklight-specific error decoding for better error messages. pub async fn overestimate_gas( call: &CallBuilder, ) -> anyhow::Result { diff --git a/crates/contract-clients-common/Cargo.toml b/crates/contract-clients-common/Cargo.toml new file mode 100644 index 0000000..4abd78b --- /dev/null +++ b/crates/contract-clients-common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "contract-clients-common" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } +futures-util = "0.3" +tokio = { version = "1.49", features = ["sync"] } +tracing = "0.1" diff --git a/crates/erc-8004-contract-clients/src/common/errors.rs b/crates/contract-clients-common/src/errors.rs similarity index 79% rename from crates/erc-8004-contract-clients/src/common/errors.rs rename to crates/contract-clients-common/src/errors.rs index 3975846..6c4de35 100644 --- a/crates/erc-8004-contract-clients/src/common/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -14,9 +14,9 @@ //! - Selector: `0x4e487b71` //! - Includes overflow, division by zero, array bounds, etc. //! -//! 3. **Custom Contract Errors** - Gas-efficient custom errors from contract ABIs -//! - Add support here when custom errors are introduced in registry contracts +//! 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 //! @@ -26,23 +26,12 @@ //! → "contract: request already exists" (human-readable!) //! ``` //! -//! ## Example -//! -//! Instead of seeing: -//! ```text -//! error: execution reverted: 0x08c379a0000000... -//! ``` -//! -//! You now see: -//! ```text -//! error: contract: request already exists -//! ``` -//! //! ## Main Entry Points //! //! - [`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,23 +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 - -// Note: If custom errors are added to the registry contracts, re-export them here -// and extend `decode_revert` with the appropriate decoding. - // ============================================================================ // DecodedRevert Enum - The Result of Decoding // ============================================================================ @@ -101,6 +73,7 @@ sol! { /// |---------|----------------| /// | `ErrorString` | `require()` failed with a message | /// | `Panic` | `assert()` failed or arithmetic error | +/// | `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)] @@ -114,6 +87,10 @@ pub enum DecodedRevert { /// See [`panic_reason`] for code meanings. Panic(u64), + /// 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. RawRevert(Bytes), @@ -128,6 +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::CustomError(msg) => write!(f, "{}", msg), DecodedRevert::RawRevert(data) => write!(f, "Raw revert data: {}", data), DecodedRevert::NoRevertData(details) => write!(f, "No revert data ({})", details), } @@ -157,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", @@ -182,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 /// @@ -196,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); // "contract: request 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()); @@ -220,7 +229,12 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { } } - // Step 2: Unknown error - return raw bytes so user can debug + // 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 // This allows users to manually decode or report the unknown error type DecodedRevert::RawRevert(data.clone()) } @@ -252,10 +266,24 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { /// /// 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) => { @@ -265,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)) @@ -287,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 @@ -301,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)); @@ -312,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)) @@ -345,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", @@ -387,7 +433,9 @@ 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) + })); } } } @@ -439,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; } @@ -533,6 +592,9 @@ mod tests { let panic = DecodedRevert::Panic(1); assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); + + let custom = DecodedRevert::CustomError("Custom error".to_string()); + assert_eq!(format!("{}", custom), "Custom error"); } /// Test extracting revert data from various error string formats. @@ -559,4 +621,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..cb6f33d --- /dev/null +++ b/crates/contract-clients-common/src/lib.rs @@ -0,0 +1,54 @@ +//! # 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 tx_submitter; + +/// 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<&P, D>, +) -> 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/common/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs similarity index 73% rename from crates/blacklight-contract-clients/src/common/tx_submitter.rs rename to crates/contract-clients-common/src/tx_submitter.rs index 11a7ecf..c43147a 100644 --- a/crates/blacklight-contract-clients/src/common/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -1,7 +1,12 @@ use crate::common::overestimate_gas; use alloy::{ - consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, - rpc::types::TransactionReceipt, sol_types::SolInterface, + consensus::Transaction, + contract::CallBuilder, + eips::BlockId, + primitives::B256, + providers::Provider, + rpc::types::TransactionReceipt, + sol_types::SolInterface, }; use anyhow::{Result, anyhow}; use std::fmt::Debug; @@ -10,6 +15,8 @@ use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; +use crate::errors::decode_any_error; + #[derive(Clone)] pub struct TransactionSubmitter { tx_lock: Arc>, @@ -88,13 +95,50 @@ impl TransactionSubmitter { } } - return Err(anyhow!("{method} reverted on-chain. Tx hash: {tx_hash:?}")); + // Try to get the revert reason by replaying the call at the block it was included + let revert_reason = self + .get_revert_reason(&call, receipt.block_number) + .await + .unwrap_or_else(|| "unknown reason".to_string()); + + return Err(anyhow!( + "{method} reverted on-chain: {revert_reason}. Tx hash: {tx_hash:?}" + )); } Ok(tx_hash) } - pub fn with_gas_buffer(&self) -> Self { + /// Attempt to get the revert reason by replaying the call at a specific block. + /// + /// When a transaction reverts on-chain (after passing simulation), we can replay + /// the call at the exact block it was included in to capture the revert data. + async fn get_revert_reason( + &self, + call: &CallBuilder, + block_number: Option, + ) -> Option + where + P: Provider + Clone, + D: alloy::contract::CallDecoder + Clone, + { + let block_id = block_number.map(BlockId::number)?; + + // Replay the call at the specific block to get the revert data + match call.clone().block(block_id).call().await { + Ok(_) => { + // Unexpectedly succeeded - state must have changed + Some("transaction reverted but replay succeeded (state changed)".to_string()) + } + Err(e) => { + // Decode the error to get the revert reason + let decoded = self.decode_error(e); + Some(decoded) + } + } + } + + pub fn with_gas_limit(&self, limit: u64) -> Self { let mut this = self.clone(); this.gas_buffer = true; this @@ -103,7 +147,7 @@ impl TransactionSubmitter { fn decode_error(&self, error: alloy::contract::Error) -> String { match error.try_decode_into_interface_error::() { Ok(error) => format!("{error:?}"), - Err(error) => super::errors::decode_any_error(&error).to_string(), + Err(error) => decode_any_error(&error).to_string(), } } @@ -149,7 +193,7 @@ impl TransactionSubmitter { tx_max_priority_fee = ?tx_max_priority_fee, actual_priority_fee = ?actual_priority_fee, estimated_priority_fee = ?estimated_priority_fee, - "💰 transaction gas details (priority fee may be too low)" + "transaction gas details (priority fee may be too low)" ); } else { info!( @@ -163,7 +207,7 @@ impl TransactionSubmitter { tx_max_priority_fee = ?tx_max_priority_fee, actual_priority_fee = ?actual_priority_fee, estimated_priority_fee = ?estimated_priority_fee, - "💰 transaction gas details" + "transaction gas details" ); } } diff --git a/crates/erc-8004-contract-clients/Cargo.toml b/crates/erc-8004-contract-clients/Cargo.toml index 83df7a9..ff5b58b 100644 --- a/crates/erc-8004-contract-clients/Cargo.toml +++ b/crates/erc-8004-contract-clients/Cargo.toml @@ -7,6 +7,7 @@ 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" diff --git a/crates/erc-8004-contract-clients/src/common/event_helper.rs b/crates/erc-8004-contract-clients/src/common/event_helper.rs deleted file mode 100644 index 3e2e56a..0000000 --- a/crates/erc-8004-contract-clients/src/common/event_helper.rs +++ /dev/null @@ -1,188 +0,0 @@ -//! # Event Helper -//! -//! This module provides common event listening and querying patterns to reduce boilerplate -//! across contract clients. -//! -//! ## Usage -//! -//! ```ignore -//! use crate::contract_client::event_helper::BlockRange; -//! -//! // Query with block range -//! let range = BlockRange::last_n_blocks(1000); -//! ``` - -use anyhow::Result; -use futures_util::StreamExt; -use tracing::error; - -/// Represents a block range for event queries. -/// -/// Provides convenient constructors for common query patterns. -#[derive(Debug, Clone, Copy)] -pub struct BlockRange { - pub from_block: u64, - pub to_block: Option, -} - -impl BlockRange { - /// Create a range from a specific block to the latest block. - pub fn from(from_block: u64) -> Self { - Self { - from_block, - to_block: None, - } - } - - /// Create a range between two specific blocks (inclusive). - pub fn between(from_block: u64, to_block: u64) -> Self { - Self { - from_block, - to_block: Some(to_block), - } - } - - /// Create a range for the last N blocks from the current block. - /// - /// Note: This requires knowing the current block number, so it returns - /// a function that takes the current block and returns the range. - pub fn from_lookback(current_block: u64, lookback_blocks: u64) -> Self { - Self { - from_block: current_block.saturating_sub(lookback_blocks), - to_block: None, - } - } - - /// Query the entire blockchain history. - pub fn all() -> Self { - Self { - from_block: 0, - to_block: None, - } - } -} - -impl Default for BlockRange { - fn default() -> Self { - Self::all() - } -} - -/// Listen to events with a filter predicate. -/// -/// This is the base event listener that reduces boilerplate by handling: -/// - Stream iteration -/// - Error logging for both event processing and reception -/// - Graceful handling of stream termination -/// - Optional filtering via predicate -/// -/// # Type Parameters -/// -/// * `E` - The event type (must be Send) -/// * `Err` - The error type from the stream -/// * `L` - The stream type -/// * `P` - The predicate function type -/// * `F` - The callback function type -/// * `Fut` - The future returned by the callback -/// -/// # Arguments -/// -/// * `stream` - The event stream to listen to -/// * `event_name` - Name of the event for logging purposes -/// * `predicate` - Function that returns true if the event should be processed -/// * `callback` - Async function to process each matching event -pub async fn listen_events_filtered( - mut stream: L, - event_name: &str, - predicate: P, - mut callback: F, -) -> Result<()> -where - E: Send, - Err: std::fmt::Display, - L: StreamExt> + Unpin + Send, - P: Fn(&E) -> bool + Send, - F: FnMut(E) -> Fut + Send, - Fut: std::future::Future> + Send, -{ - while let Some(event_result) = stream.next().await { - match event_result { - Ok((event, _log)) => { - if predicate(&event) - && let Err(e) = callback(event).await - { - error!("Error processing {} event: {}", event_name, e); - } - } - Err(e) => { - error!("Error receiving {} event: {}", event_name, e); - } - } - } - Ok(()) -} - -/// Listen to events from a subscription and process them with a callback. -/// -/// This is a convenience wrapper around [`listen_events_filtered`] that processes -/// all events without filtering. -/// -/// # Arguments -/// -/// * `stream` - The event stream to listen to -/// * `event_name` - Name of the event for logging purposes -/// * `callback` - Async function to process each event -pub async fn listen_events( - stream: L, - event_name: &str, - callback: F, -) -> Result<()> -where - E: Send, - Err: std::fmt::Display, - L: StreamExt> + Unpin + Send, - F: FnMut(E) -> Fut + Send, - Fut: std::future::Future> + Send, -{ - listen_events_filtered(stream, event_name, |_| true, callback).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_block_range_from() { - let range = BlockRange::from(100); - assert_eq!(range.from_block, 100); - assert_eq!(range.to_block, None); - } - - #[test] - fn test_block_range_between() { - let range = BlockRange::between(100, 200); - assert_eq!(range.from_block, 100); - assert_eq!(range.to_block, Some(200)); - } - - #[test] - fn test_block_range_lookback() { - let range = BlockRange::from_lookback(1000, 100); - assert_eq!(range.from_block, 900); - assert_eq!(range.to_block, None); - } - - #[test] - fn test_block_range_lookback_underflow() { - let range = BlockRange::from_lookback(50, 100); - assert_eq!(range.from_block, 0); // saturating_sub prevents underflow - assert_eq!(range.to_block, None); - } - - #[test] - fn test_block_range_all() { - let range = BlockRange::all(); - assert_eq!(range.from_block, 0); - assert_eq!(range.to_block, None); - } -} diff --git a/crates/erc-8004-contract-clients/src/common/mod.rs b/crates/erc-8004-contract-clients/src/common/mod.rs index a8001f0..535c56b 100644 --- a/crates/erc-8004-contract-clients/src/common/mod.rs +++ b/crates/erc-8004-contract-clients/src/common/mod.rs @@ -1,22 +1,9 @@ -use crate::common::errors::decode_any_error; -use alloy::{ - contract::{CallBuilder, CallDecoder}, - providers::Provider, -}; -use anyhow::anyhow; +//! Common utilities for ERC-8004 contract clients. +//! +//! This module re-exports shared utilities from `contract-clients-common`. -pub mod errors; -pub mod event_helper; -pub mod tx_submitter; - -pub async fn overestimate_gas( - call: &CallBuilder<&P, D>, -) -> 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) -} +// Re-export everything from the shared crate +pub use contract_clients_common::errors; +pub use contract_clients_common::event_helper; +pub use contract_clients_common::overestimate_gas; +pub use contract_clients_common::tx_submitter; diff --git a/crates/erc-8004-contract-clients/src/common/tx_submitter.rs b/crates/erc-8004-contract-clients/src/common/tx_submitter.rs deleted file mode 100644 index 08dc3fd..0000000 --- a/crates/erc-8004-contract-clients/src/common/tx_submitter.rs +++ /dev/null @@ -1,160 +0,0 @@ -use alloy::{ - consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, - rpc::types::TransactionReceipt, sol_types::SolInterface, -}; -use anyhow::{Result, anyhow}; -use std::fmt::Debug; -use std::marker::PhantomData; -use std::sync::Arc; -use tokio::sync::Mutex; -use tracing::{info, warn}; - -#[derive(Clone)] -pub(crate) struct TransactionSubmitter { - tx_lock: Arc>, - gas_limit: Option, - _decoder: PhantomData, -} - -impl TransactionSubmitter { - pub(crate) fn new(tx_lock: Arc>) -> Self { - Self { - tx_lock, - gas_limit: None, - _decoder: PhantomData, - } - } - - pub(crate) async fn invoke(&self, method: &str, call: CallBuilder) -> Result - where - P: Provider + Clone, - D: alloy::contract::CallDecoder + Clone, - { - // 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}")); - } - - let call = match self.gas_limit { - Some(gas) => call.gas(gas), - None => call, - }; - - let provider = call.provider.clone(); - let estimate = provider.estimate_eip1559_fees().await?; - - // Our L2 requires a minimum priority fee of 1 wei - let priority_fee = 1u128; - let call = call - .max_priority_fee_per_gas(priority_fee) - .max_fee_per_gas(estimate.max_fee_per_gas); - - let estimated_gas = call.clone().estimate_gas().await?; - - // 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}") - })?; - - // Wait for receipt - let receipt = pending.get_receipt().await?; - let tx_hash = receipt.transaction_hash; - - Self::log_fee_details( - &provider, - method, - tx_hash, - &receipt, - estimated_gas, - estimate.max_priority_fee_per_gas, - ) - .await; - - // Validate success - if !receipt.status() { - if let Some(limit) = self.gas_limit { - let used = receipt.gas_used; - if used >= limit { - return Err(anyhow!( - "{method} ran out of gas (used {used} of {limit} limit). Tx: {tx_hash:?}" - )); - } - } - - return Err(anyhow!("{method} reverted on-chain. Tx hash: {tx_hash:?}")); - } - - Ok(tx_hash) - } - - fn decode_error(&self, error: alloy::contract::Error) -> String { - match error.try_decode_into_interface_error::() { - Ok(error) => format!("{error:?}"), - Err(error) => super::errors::decode_any_error(&error).to_string(), - } - } - - async fn log_fee_details( - provider: &P, - method: &str, - tx_hash: B256, - receipt: &TransactionReceipt, - estimated_gas: u64, - estimated_priority_fee: u128, - ) { - // Fetch actual transaction to get the real fee parameters - let (tx_max_fee, tx_max_priority_fee) = - match provider.get_transaction_by_hash(tx_hash).await { - Ok(Some(tx)) => (Some(tx.max_fee_per_gas()), tx.max_priority_fee_per_gas()), - _ => (None, None), - }; - - // Calculate actual priority fee paid: effective_gas_price - base_fee - let actual_priority_fee = if let Some(block_num) = receipt.block_number { - provider - .get_block_by_number(block_num.into()) - .await - .ok() - .flatten() - .and_then(|b| b.header.base_fee_per_gas) - .map(|base_fee| receipt.effective_gas_price.saturating_sub(base_fee as u128)) - } else { - None - }; - - let total_cost = receipt.effective_gas_price * receipt.gas_used as u128; - let actual_priority_fee = actual_priority_fee.unwrap_or(0); - if actual_priority_fee < estimated_priority_fee.saturating_sub(1_000_000_000u128) { - warn!( - method = %method, - tx_hash = ?tx_hash, - effective_gas_price = receipt.effective_gas_price, - gas_used = receipt.gas_used, - estimated_gas = ?estimated_gas, - total_cost, - tx_max_fee = ?tx_max_fee, - tx_max_priority_fee = ?tx_max_priority_fee, - actual_priority_fee = ?actual_priority_fee, - estimated_priority_fee = ?estimated_priority_fee, - "💰 transaction gas details (priority fee may be too low)" - ); - } else { - info!( - method = %method, - tx_hash = ?tx_hash, - effective_gas_price = receipt.effective_gas_price, - gas_used = receipt.gas_used, - estimated_gas = ?estimated_gas, - total_cost, - tx_max_fee = ?tx_max_fee, - tx_max_priority_fee = ?tx_max_priority_fee, - actual_priority_fee = ?actual_priority_fee, - estimated_priority_fee = ?estimated_priority_fee, - "💰 transaction gas details" - ); - } - } -} diff --git a/docker-compose.yml b/docker-compose.yml index eff4e64..43db1b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "8545:8545" networks: - blacklight-network - command: ["--accounts", "15"] # Define the number of accounts to create + command: ["--accounts", "20"] # Define the number of accounts to create 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 From 4c8b0822f0d1bde054ff31c129dd7cb3ed7fc918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Mon, 2 Feb 2026 16:30:33 +0100 Subject: [PATCH 03/17] chore: unified simulators under a single trait --- .gitignore | 3 +- Cargo.toml | 3 +- docker/Dockerfile | 20 +- erc-8004-simulator/Cargo.toml | 16 -- erc-8004-simulator/src/args.rs | 119 ----------- erc-8004-simulator/src/main.rs | 180 ---------------- nilcc-simulator/src/args.rs | 88 -------- nilcc-simulator/src/main.rs | 168 --------------- {nilcc-simulator => simulator}/Cargo.toml | 6 +- simulator/src/common.rs | 83 ++++++++ simulator/src/erc8004.rs | 241 ++++++++++++++++++++++ simulator/src/main.rs | 41 ++++ simulator/src/nilcc.rs | 205 ++++++++++++++++++ 13 files changed, 586 insertions(+), 587 deletions(-) delete mode 100644 erc-8004-simulator/Cargo.toml delete mode 100644 erc-8004-simulator/src/args.rs delete mode 100644 erc-8004-simulator/src/main.rs delete mode 100644 nilcc-simulator/src/args.rs delete mode 100644 nilcc-simulator/src/main.rs rename {nilcc-simulator => simulator}/Cargo.toml (83%) create mode 100644 simulator/src/common.rs create mode 100644 simulator/src/erc8004.rs create mode 100644 simulator/src/main.rs create mode 100644 simulator/src/nilcc.rs 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 426abeb..2301d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,7 @@ members = [ "crates/contract-clients-common", "crates/erc-8004-contract-clients", "crates/state-file", - "erc-8004-simulator", "keeper", "monitor", - "nilcc-simulator" + "simulator" ] diff --git a/docker/Dockerfile b/docker/Dockerfile index c3d991f..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,8 +57,7 @@ RUN mkdir -p ~/.cargo && \ # Copy project files COPY Cargo.toml ./ COPY crates ./crates -COPY nilcc-simulator ./nilcc-simulator -COPY erc-8004-simulator ./erc-8004-simulator +COPY simulator ./simulator COPY keeper ./keeper COPY blacklight-node ./blacklight-node COPY monitor ./monitor @@ -85,8 +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/erc-8004-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/ @@ -114,17 +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 +# Runtime stage for erc_8004_simulator (ERC-8004 validation requests) FROM base_release AS erc_8004_simulator -COPY --from=builder /out/bin/erc-8004-simulator /usr/local/bin/erc-8004-simulator -ENTRYPOINT ["/usr/local/bin/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/erc-8004-simulator/Cargo.toml b/erc-8004-simulator/Cargo.toml deleted file mode 100644 index b00ff7a..0000000 --- a/erc-8004-simulator/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "erc-8004-simulator" -version = "0.1.0" -edition = "2024" - -[dependencies] -alloy = { version = "1.1", features = ["contract", "providers"] } -anyhow = "1.0" -clap = { version = "4.5", features = ["derive", "env"] } -tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } - -erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } -chain-args = { path = "../crates/chain-args" } -state-file = { path = "../crates/state-file" } diff --git a/erc-8004-simulator/src/args.rs b/erc-8004-simulator/src/args.rs deleted file mode 100644 index 30c302a..0000000 --- a/erc-8004-simulator/src/args.rs +++ /dev/null @@ -1,119 +0,0 @@ -use alloy::primitives::Address; -use anyhow::Result; -use clap::Parser; - -use state_file::StateFile; -use tracing::info; - -const STATE_FILE_SIMULATOR: &str = "erc_8004_simulator.env"; - -/// Default slot interval in milliseconds - how often simulator submits validation requests -#[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 ERC-8004 simulator -#[derive(Parser, Debug)] -#[command(name = "erc_8004_simulator")] -#[command(about = "ERC-8004 Simulator - Registers agents and submits validation requests", long_about = None)] -pub struct CliArgs { - /// 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, - - /// HeartbeatManager contract address to submit validation requests to - #[arg(long, env = "HEARTBEAT_MANAGER_ADDRESS")] - pub heartbeat_manager_address: Option, -} - -/// Simulator configuration with all required values resolved -#[derive(Debug, Clone)] -pub struct SimulatorConfig { - pub rpc_url: String, - pub identity_registry_contract_address: Address, - pub validation_registry_contract_address: Address, - pub private_key: String, - pub agent_uri: String, - pub heartbeat_manager_address: Address, - 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); - - // Load RPC URL with priority - let rpc_url = cli_args - .rpc_url - .or_else(|| state_file.load_value("RPC_URL")) - .unwrap_or_else(|| "http://127.0.0.1:8545".to_string()); - - // Load IdentityRegistry contract address - let identity_registry_contract_address = cli_args - .identity_registry_contract_address - .or_else(|| state_file.load_value("IDENTITY_REGISTRY_CONTRACT_ADDRESS")) - .unwrap_or_else(|| "0x5FbDB2315678afecb367f032d93F642f64180aa3".to_string()) - .parse::
()?; - - // Load ValidationRegistry contract address - let validation_registry_contract_address = cli_args - .validation_registry_contract_address - .or_else(|| state_file.load_value("VALIDATION_REGISTRY_CONTRACT_ADDRESS")) - .unwrap_or_else(|| "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512".to_string()) - .parse::
()?; - - // Load private key with priority (Anvil account #3 as default) - let private_key = cli_args - .private_key - .or_else(|| state_file.load_value("PRIVATE_KEY")) - .unwrap_or_else(|| { - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() - }); - - // Load agent URI - let agent_uri = cli_args - .agent_uri - .or_else(|| state_file.load_value("AGENT_URI")) - .unwrap_or_else(|| "https://example.com/agent".to_string()); - - // Load HeartbeatManager contract address - let heartbeat_manager_address = cli_args - .heartbeat_manager_address - .or_else(|| state_file.load_value("HEARTBEAT_MANAGER_ADDRESS")) - .unwrap_or_else(|| "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707".to_string()) - .parse::
()?; - - info!( - "Loaded SimulatorConfig: rpc_url={rpc_url}, identity_registry={identity_registry_contract_address}, validation_registry={validation_registry_contract_address}" - ); - - Ok(SimulatorConfig { - rpc_url, - identity_registry_contract_address, - validation_registry_contract_address, - private_key, - agent_uri, - heartbeat_manager_address, - slot_ms: DEFAULT_SLOT_MS, - }) - } -} diff --git a/erc-8004-simulator/src/main.rs b/erc-8004-simulator/src/main.rs deleted file mode 100644 index dad9d33..0000000 --- a/erc-8004-simulator/src/main.rs +++ /dev/null @@ -1,180 +0,0 @@ -use alloy::primitives::{B256, U256, keccak256}; -use anyhow::Result; -use args::{CliArgs, SimulatorConfig}; -use clap::Parser; -use erc_8004_contract_clients::{ContractConfig, Erc8004Client}; -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?; - - // Register the agent first - let agent_id = register_agent(&client, &config).await?; - - // Run the validation request submission loop - run_submission_loop(client, config, agent_id).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.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: &SimulatorConfig) -> 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"); - - // Verify registration by querying the agent - 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) -} - -async fn run_submission_loop( - client: Erc8004Client, - config: SimulatorConfig, - agent_id: U256, -) -> Result<()> { - let mut ticker = interval(Duration::from_millis(config.slot_ms)); - let mut slot = 0u64; - let client = Arc::new(client); - let config = Arc::new(config); - - 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 config = Arc::clone(&config); - tokio::spawn(async move { - if let Err(e) = submit_validation_request(&client, &config, agent_id, slot).await { - error!(slot, error = %e, "Validation request submission failed"); - } - }); - } -} - -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_MS: u64 = 500; - -async fn submit_validation_request( - client: &Arc, - config: &Arc, - agent_id: U256, - slot: u64, -) -> Result<()> { - let mut last_error = None; - - for attempt in 0..MAX_RETRIES { - // Get current block number for snapshot ID (use block - 1 for committee selection) - let block_number = client.get_block_number().await?; - let snapshot_id = block_number.saturating_sub(1); - - // Use same URI but include snapshot_id in hash to make each request unique - 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, - heartbeat_manager = %config.heartbeat_manager_address, - snapshot_id = snapshot_id, - request_uri = %request_uri, - "Submitting validation request" - ); - } else { - info!(slot, attempt, "Retrying validation request submission"); - } - - match client - .validation_registry - .validation_request( - config.heartbeat_manager_address, - agent_id, - request_uri.clone(), - request_hash, - snapshot_id, - ) - .await - { - Ok(tx_hash) => { - info!(slot, tx_hash = ?tx_hash, "Validation request 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/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 fc38287..0000000 --- a/nilcc-simulator/src/main.rs +++ /dev/null @@ -1,168 +0,0 @@ -use anyhow::Result; -use args::{CliArgs, SimulatorConfig}; -use blacklight_contract_clients::{ - htx::{Htx, JsonHtx, 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 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 -} - -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::Erc8004(_) => { - // ERC-8004 HTXs are not loaded from JSON files, skip - 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"); - } - - 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..547c391 --- /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 = 3000; + +#[cfg(not(debug_assertions))] +pub const DEFAULT_SLOT_MS: u64 = 5000; + +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..377cba0 --- /dev/null +++ b/simulator/src/erc8004.rs @@ -0,0 +1,241 @@ +use alloy::primitives::{keccak256, Address, B256, U256}; +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::{retry_submit, Simulator, DEFAULT_SLOT_MS}; + +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, + + /// HeartbeatManager contract address to submit validation requests to + #[arg(long, env = "HEARTBEAT_MANAGER_ADDRESS")] + pub heartbeat_manager_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, + pub heartbeat_manager_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 heartbeat_manager_address = args + .heartbeat_manager_address + .or_else(|| state_file.load_value("HEARTBEAT_MANAGER_ADDRESS")) + .unwrap_or_else(|| "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707".to_string()) + .parse::
()?; + + info!( + "Loaded Erc8004Config: rpc_url={rpc_url}, identity_registry={identity_registry_contract_address}, validation_registry={validation_registry_contract_address}" + ); + + Ok(Self { + rpc_url, + identity_registry_contract_address, + validation_registry_contract_address, + private_key, + agent_uri, + heartbeat_manager_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, + heartbeat_manager = %config.heartbeat_manager_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.heartbeat_manager_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..5e67c03 --- /dev/null +++ b/simulator/src/main.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use clap::Parser; +use tracing_subscriber::{fmt, prelude::*, EnvFilter}; + +mod erc8004; +mod nilcc; +mod common; + +#[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..0914f59 --- /dev/null +++ b/simulator/src/nilcc.rs @@ -0,0 +1,205 @@ +use alloy::primitives::Address; +use anyhow::Result; +use blacklight_contract_clients::{ + htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, + BlacklightClient, ContractConfig, +}; +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::{retry_submit, Simulator, DEFAULT_SLOT_MS}; + +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 + } +} From ec6856a7bec631ae99dd58b4a54fa6a7d8e42086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Mon, 2 Feb 2026 16:45:13 +0100 Subject: [PATCH 04/17] chore: cleaned up htx validation for ERC-8004 --- crates/blacklight-contract-clients/src/htx.rs | 63 ++++++------------- crates/contract-clients-common/src/lib.rs | 2 +- .../src/tx_submitter.rs | 60 +++--------------- simulator/src/erc8004.rs | 4 +- simulator/src/main.rs | 4 +- simulator/src/nilcc.rs | 7 ++- 6 files changed, 36 insertions(+), 104 deletions(-) diff --git a/crates/blacklight-contract-clients/src/htx.rs b/crates/blacklight-contract-clients/src/htx.rs index 810aa06..2ff6624 100644 --- a/crates/blacklight-contract-clients/src/htx.rs +++ b/crates/blacklight-contract-clients/src/htx.rs @@ -81,10 +81,8 @@ pub enum PhalaHtx { V1(PhalaHtxV1), } -// ERC-8004 Validation HTX - ABI encoded from ValidationRegistry -// Solidity: abi.encode(validatorAddress, agentId, requestURI, requestHash) - -/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes +/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes. +/// Format: `abi.encode(validatorAddress, agentId, requestURI, requestHash)` #[derive(Debug, Clone)] pub struct Erc8004Htx { pub validator_address: Address, @@ -94,11 +92,8 @@ pub struct Erc8004Htx { } impl Erc8004Htx { - /// Try to decode ABI-encoded ERC-8004 validation data - /// Format: abi.encode(validatorAddress, agentId, requestURI, requestHash) + /// Try to decode ABI-encoded ERC-8004 validation data. pub fn try_decode(data: &[u8]) -> Result { - // Use DynSolType::Tuple for proper ABI decoding of abi.encode() output - // abi.encode() produces parameter encoding, so we use abi_decode_params on a tuple let tuple_type = DynSolType::Tuple(vec![ DynSolType::Address, DynSolType::Uint(256), @@ -110,7 +105,6 @@ impl Erc8004Htx { .abi_decode_params(data) .map_err(|e| Erc8004DecodeError(e.to_string()))?; - // Extract values from the decoded tuple let values = match decoded { DynSolValue::Tuple(values) => values, _ => return Err(Erc8004DecodeError("Expected tuple".to_string())), @@ -163,7 +157,7 @@ impl std::fmt::Display for Erc8004DecodeError { impl std::error::Error for Erc8004DecodeError {} -// Unified HTX type that can represent nilCC, Phala, and ERC-8004 HTXs +/// Unified HTX type that can represent nilCC, Phala, and ERC-8004 HTXs. #[derive(Debug, Clone)] pub enum Htx { Nillion(NillionHtx), @@ -171,8 +165,8 @@ pub enum Htx { Erc8004(Erc8004Htx), } -/// JSON-serializable HTX types (Nillion and Phala only, not ERC-8004) -/// Use this for loading HTXs from JSON files. +/// JSON-serializable HTX types (Nillion and Phala only, not ERC-8004). +/// Used for loading HTXs from JSON files. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "provider", rename_all = "camelCase")] pub enum JsonHtx { @@ -190,29 +184,14 @@ impl From for Htx { } impl Htx { - /// Parse HTX from raw bytes, trying JSON first then ABI decoding + /// Parse HTX from raw bytes, trying JSON first then ABI decoding. pub fn try_parse(data: &[u8]) -> Result { - // First try JSON parsing (nilCC and Phala) - match serde_json::from_slice::(data) { - Ok(json_htx) => { - return Ok(match json_htx { - JsonHtx::Nillion(htx) => Htx::Nillion(htx), - JsonHtx::Phala(htx) => Htx::Phala(htx), - }); - } - Err(json_err) => { - tracing::debug!(error = %json_err, "JSON parsing failed, trying ABI decode"); - } + if let Ok(json_htx) = serde_json::from_slice::(data) { + return Ok(json_htx.into()); } - // Then try ABI decoding (ERC-8004) - match Erc8004Htx::try_decode(data) { - Ok(erc8004_htx) => { - return Ok(Htx::Erc8004(erc8004_htx)); - } - Err(abi_err) => { - tracing::debug!(error = %abi_err, data_len = data.len(), "ABI decoding failed"); - } + if let Ok(erc8004_htx) = Erc8004Htx::try_decode(data) { + return Ok(erc8004_htx.into()); } Err(HtxParseError::UnknownFormat) @@ -248,18 +227,8 @@ impl TryFrom<&Htx> for Bytes { fn try_from(htx: &Htx) -> Result { match htx { - Htx::Nillion(htx) => { - let json_htx = JsonHtx::Nillion(htx.clone()); - let json = canonicalize_json(&serde_json::to_value(json_htx)?); - let json = serde_json::to_string(&json)?; - Ok(Bytes::from(json.into_bytes())) - } - Htx::Phala(htx) => { - let json_htx = JsonHtx::Phala(htx.clone()); - let json = canonicalize_json(&serde_json::to_value(json_htx)?); - let json = serde_json::to_string(&json)?; - Ok(Bytes::from(json.into_bytes())) - } + 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) => { let tuple = ( htx.validator_address, @@ -273,6 +242,12 @@ impl TryFrom<&Htx> for Bytes { } } +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())) +} + fn canonicalize_json(value: &Value) -> Value { match value { Value::Object(map) => { diff --git a/crates/contract-clients-common/src/lib.rs b/crates/contract-clients-common/src/lib.rs index cb6f33d..a974295 100644 --- a/crates/contract-clients-common/src/lib.rs +++ b/crates/contract-clients-common/src/lib.rs @@ -42,7 +42,7 @@ pub mod tx_submitter; /// /// The estimated gas with a 50% buffer added. pub async fn overestimate_gas( - call: &CallBuilder<&P, D>, + call: &CallBuilder, ) -> anyhow::Result { // Estimate gas and add a 50% buffer let estimated_gas = call.estimate_gas().await.map_err(|e| { diff --git a/crates/contract-clients-common/src/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs index c43147a..206e9ab 100644 --- a/crates/contract-clients-common/src/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -1,12 +1,7 @@ -use crate::common::overestimate_gas; +use crate::overestimate_gas; use alloy::{ - consensus::Transaction, - contract::CallBuilder, - eips::BlockId, - primitives::B256, - providers::Provider, - rpc::types::TransactionReceipt, - sol_types::SolInterface, + consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, + rpc::types::TransactionReceipt, sol_types::SolInterface, }; use anyhow::{Result, anyhow}; use std::fmt::Debug; @@ -15,8 +10,6 @@ use std::sync::Arc; use tokio::sync::Mutex; use tracing::{info, warn}; -use crate::errors::decode_any_error; - #[derive(Clone)] pub struct TransactionSubmitter { tx_lock: Arc>, @@ -95,50 +88,13 @@ impl TransactionSubmitter { } } - // Try to get the revert reason by replaying the call at the block it was included - let revert_reason = self - .get_revert_reason(&call, receipt.block_number) - .await - .unwrap_or_else(|| "unknown reason".to_string()); - - return Err(anyhow!( - "{method} reverted on-chain: {revert_reason}. Tx hash: {tx_hash:?}" - )); + return Err(anyhow!("{method} reverted on-chain. Tx hash: {tx_hash:?}")); } Ok(tx_hash) } - /// Attempt to get the revert reason by replaying the call at a specific block. - /// - /// When a transaction reverts on-chain (after passing simulation), we can replay - /// the call at the exact block it was included in to capture the revert data. - async fn get_revert_reason( - &self, - call: &CallBuilder, - block_number: Option, - ) -> Option - where - P: Provider + Clone, - D: alloy::contract::CallDecoder + Clone, - { - let block_id = block_number.map(BlockId::number)?; - - // Replay the call at the specific block to get the revert data - match call.clone().block(block_id).call().await { - Ok(_) => { - // Unexpectedly succeeded - state must have changed - Some("transaction reverted but replay succeeded (state changed)".to_string()) - } - Err(e) => { - // Decode the error to get the revert reason - let decoded = self.decode_error(e); - Some(decoded) - } - } - } - - pub fn with_gas_limit(&self, limit: u64) -> Self { + pub fn with_gas_buffer(&self) -> Self { let mut this = self.clone(); this.gas_buffer = true; this @@ -147,7 +103,7 @@ impl TransactionSubmitter { fn decode_error(&self, error: alloy::contract::Error) -> String { match error.try_decode_into_interface_error::() { Ok(error) => format!("{error:?}"), - Err(error) => decode_any_error(&error).to_string(), + Err(error) => super::errors::decode_any_error(&error).to_string(), } } @@ -193,7 +149,7 @@ impl TransactionSubmitter { tx_max_priority_fee = ?tx_max_priority_fee, actual_priority_fee = ?actual_priority_fee, estimated_priority_fee = ?estimated_priority_fee, - "transaction gas details (priority fee may be too low)" + "💰 transaction gas details (priority fee may be too low)" ); } else { info!( @@ -207,7 +163,7 @@ impl TransactionSubmitter { tx_max_priority_fee = ?tx_max_priority_fee, actual_priority_fee = ?actual_priority_fee, estimated_priority_fee = ?estimated_priority_fee, - "transaction gas details" + "💰 transaction gas details" ); } } diff --git a/simulator/src/erc8004.rs b/simulator/src/erc8004.rs index 377cba0..f0f22a9 100644 --- a/simulator/src/erc8004.rs +++ b/simulator/src/erc8004.rs @@ -1,4 +1,4 @@ -use alloy::primitives::{keccak256, Address, B256, U256}; +use alloy::primitives::{Address, B256, U256, keccak256}; use anyhow::Result; use clap::Args; use erc_8004_contract_clients::{ContractConfig, Erc8004Client}; @@ -6,7 +6,7 @@ use state_file::StateFile; use std::sync::Arc; use tracing::{info, warn}; -use crate::common::{retry_submit, Simulator, DEFAULT_SLOT_MS}; +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; const STATE_FILE_SIMULATOR: &str = "erc_8004_simulator.env"; diff --git a/simulator/src/main.rs b/simulator/src/main.rs index 5e67c03..dd26f64 100644 --- a/simulator/src/main.rs +++ b/simulator/src/main.rs @@ -1,10 +1,10 @@ use anyhow::Result; use clap::Parser; -use tracing_subscriber::{fmt, prelude::*, EnvFilter}; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; +mod common; mod erc8004; mod nilcc; -mod common; #[derive(Parser, Debug)] #[command(name = "simulator")] diff --git a/simulator/src/nilcc.rs b/simulator/src/nilcc.rs index 0914f59..5dc5735 100644 --- a/simulator/src/nilcc.rs +++ b/simulator/src/nilcc.rs @@ -1,8 +1,8 @@ use alloy::primitives::Address; use anyhow::Result; use blacklight_contract_clients::{ - htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, BlacklightClient, ContractConfig, + htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, }; use chain_args::{ChainArgs, ChainConfig}; use clap::Args; @@ -11,7 +11,7 @@ use state_file::StateFile; use std::sync::Arc; use tracing::{info, warn}; -use crate::common::{retry_submit, Simulator, DEFAULT_SLOT_MS}; +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; const STATE_FILE_SIMULATOR: &str = "nilcc_simulator.env"; @@ -129,7 +129,8 @@ impl NilccSimulator { 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.workload_id.current = + format!("{}-{:x}", htx.workload_id.current, nonce); } Htx::Phala(PhalaHtx::V1(htx)) => { htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); From a71cdaedfe46a8ad3beba335b1afc436ed29b290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Wed, 4 Feb 2026 11:53:47 +0100 Subject: [PATCH 05/17] chore: move common contract utilities to a single crate --- .../src/common/errors.rs | 145 ------------------ .../src/common/mod.rs | 34 ---- .../src/heartbeat_manager.rs | 8 +- crates/blacklight-contract-clients/src/lib.rs | 1 - .../src/nil_token.rs | 8 +- .../src/protocol_config.rs | 2 +- .../src/staking_operators.rs | 3 +- .../src/common/mod.rs | 9 -- .../src/identity_registry.rs | 5 +- crates/erc-8004-contract-clients/src/lib.rs | 1 - .../src/validation_registry.rs | 5 +- keeper/Cargo.toml | 1 + keeper/src/l2/escalator.rs | 8 +- keeper/src/l2/jailing.rs | 2 +- keeper/src/l2/rewards.rs | 8 +- 15 files changed, 27 insertions(+), 213 deletions(-) delete mode 100644 crates/blacklight-contract-clients/src/common/errors.rs delete mode 100644 crates/blacklight-contract-clients/src/common/mod.rs delete mode 100644 crates/erc-8004-contract-clients/src/common/mod.rs diff --git a/crates/blacklight-contract-clients/src/common/errors.rs b/crates/blacklight-contract-clients/src/common/errors.rs deleted file mode 100644 index 4f3ffa8..0000000 --- a/crates/blacklight-contract-clients/src/common/errors.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! # Blacklight Error Handling -//! -//! This module extends `contract-clients-common` error handling with -//! StakingOperators-specific custom error decoding. -//! -//! ## Supported Custom Errors -//! -//! - `StakingOperatorsErrors` - Custom errors from the StakingOperators contract -//! -//! ## Usage -//! -//! ```ignore -//! use blacklight_contract_clients::common::errors::decode_any_error; -//! -//! let decoded = decode_any_error(&some_error); -//! println!("Error: {}", decoded); -//! ``` - -use alloy::{primitives::Bytes, sol_types::SolInterface}; - -// Re-export the base types and functions from the shared crate -pub use contract_clients_common::errors::{ - DecodedRevert, decode_revert, decode_revert_with_custom, extract_revert_from_contract_error, - panic_reason, try_extract_from_string, -}; - -/// 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; - -// ============================================================================ -// StakingOperators 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. -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(), - } -} - -/// Custom error decoder for StakingOperators errors. -/// -/// This function attempts to decode raw bytes as a StakingOperators custom error. -fn decode_staking_error(data: &Bytes) -> Option { - if let Ok(err) = StakingOperatorsErrors::abi_decode(data) { - let msg = format_staking_error(&err); - Some(DecodedRevert::CustomError(msg)) - } else { - None - } -} - -// ============================================================================ -// Blacklight-specific Entry Points -// ============================================================================ - -/// Decode ANY error into a human-readable message with StakingOperators support. -/// -/// This is the **main entry point** for error decoding in blacklight contracts. -/// It extends the base `decode_any_error` with StakingOperators custom error support. -/// -/// # Arguments -/// -/// * `error` - Any error type implementing `Display` and `Debug` -/// -/// # Returns -/// -/// A [`DecodedRevert`] - never panics, always returns something useful. -pub fn decode_any_error(error: &E) -> DecodedRevert { - contract_clients_common::errors::decode_any_error_with_custom(error, decode_staking_error) -} - -/// Decode raw revert data bytes with StakingOperators error support. -/// -/// This function attempts to decode the raw bytes, including StakingOperators -/// custom errors. -pub fn decode_revert_blacklight(data: &Bytes) -> DecodedRevert { - decode_revert_with_custom(data, decode_staking_error) -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use alloy::hex; - - /// 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_blacklight(&Bytes::from(data)); - - match decoded { - DecodedRevert::CustomError(msg) => { - assert_eq!(msg, "Insufficient stake"); - } - _ => panic!("Expected CustomError, got {:?}", decoded), - } - } - - /// Test that standard errors still work. - #[test] - fn test_decode_standard_error() { - // "test" encoded as Error(string) - let data = hex::decode( - "08c379a0\ - 0000000000000000000000000000000000000000000000000000000000000020\ - 0000000000000000000000000000000000000000000000000000000000000004\ - 7465737400000000000000000000000000000000000000000000000000000000", - ) - .unwrap(); - - let decoded = decode_revert_blacklight(&Bytes::from(data)); - - match decoded { - DecodedRevert::ErrorString(msg) => { - assert_eq!(msg, "test"); - } - _ => panic!("Expected ErrorString, got {:?}", decoded), - } - } -} 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 83abf2c..0000000 --- a/crates/blacklight-contract-clients/src/common/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! Common utilities for blacklight contract clients. -//! -//! This module re-exports shared utilities from `contract-clients-common` -//! and provides blacklight-specific extensions for error decoding. - -// Re-export shared modules -pub use contract_clients_common::event_helper; -pub use contract_clients_common::tx_submitter; - -// Provide blacklight-specific errors module with StakingOperators error support -pub mod errors; - -use alloy::{ - contract::{CallBuilder, CallDecoder}, - providers::Provider, -}; -use anyhow::anyhow; - -use crate::common::errors::decode_any_error; - -/// Estimate gas for a contract call with a 50% buffer. -/// -/// Uses blacklight-specific error decoding for better error messages. -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/lib.rs b/crates/blacklight-contract-clients/src/lib.rs index 8d046b0..4595134 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; 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/erc-8004-contract-clients/src/common/mod.rs b/crates/erc-8004-contract-clients/src/common/mod.rs deleted file mode 100644 index 535c56b..0000000 --- a/crates/erc-8004-contract-clients/src/common/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! Common utilities for ERC-8004 contract clients. -//! -//! This module re-exports shared utilities from `contract-clients-common`. - -// Re-export everything from the shared crate -pub use contract_clients_common::errors; -pub use contract_clients_common::event_helper; -pub use contract_clients_common::overestimate_gas; -pub use contract_clients_common::tx_submitter; diff --git a/crates/erc-8004-contract-clients/src/identity_registry.rs b/crates/erc-8004-contract-clients/src/identity_registry.rs index edc5130..68b0377 100644 --- a/crates/erc-8004-contract-clients/src/identity_registry.rs +++ b/crates/erc-8004-contract-clients/src/identity_registry.rs @@ -1,10 +1,10 @@ -use crate::common::tx_submitter::TransactionSubmitter; 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; @@ -39,7 +39,8 @@ pub type IdentityMetadataEntry = MetadataEntry; pub struct IdentityRegistryClient { provider: P, contract: IdentityRegistryUpgradeableInstance

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

{ diff --git a/crates/erc-8004-contract-clients/src/lib.rs b/crates/erc-8004-contract-clients/src/lib.rs index df5403b..98eb01d 100644 --- a/crates/erc-8004-contract-clients/src/lib.rs +++ b/crates/erc-8004-contract-clients/src/lib.rs @@ -1,6 +1,5 @@ use alloy::primitives::Address; -pub mod common; pub mod erc_8004_client; pub mod identity_registry; pub mod validation_registry; diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs index 58c4149..4a34787 100644 --- a/crates/erc-8004-contract-clients/src/validation_registry.rs +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -1,10 +1,10 @@ -use crate::common::tx_submitter::TransactionSubmitter; 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; @@ -28,7 +28,8 @@ use ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; #[derive(Clone)] pub struct ValidationRegistryClient { contract: ValidationRegistryUpgradeableInstance

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

{ diff --git a/keeper/Cargo.toml b/keeper/Cargo.toml index b68d42e..4dd1f59 100644 --- a/keeper/Cargo.toml +++ b/keeper/Cargo.toml @@ -16,3 +16,4 @@ 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" } diff --git a/keeper/src/l2/escalator.rs b/keeper/src/l2/escalator.rs index fda3044..d7dbb52 100644 --- a/keeper/src/l2/escalator.rs +++ b/keeper/src/l2/escalator.rs @@ -1,13 +1,13 @@ 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 contract_clients_common::errors::decode_any_error; +use contract_clients_common::tx_submitter::TransactionSubmitter; + pub(crate) struct RoundEscalator { client: Arc, state: Arc>, 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/rewards.rs b/keeper/src/l2/rewards.rs index f4b8a25..9bcee33 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}; From 1f111d48ef6450186da0575ef0bd5c50073c08f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Wed, 4 Feb 2026 13:39:05 +0100 Subject: [PATCH 06/17] feat: unified contract clients --- .../src/blacklight_client.rs | 72 +++++------ crates/contract-clients-common/Cargo.toml | 1 + crates/contract-clients-common/src/lib.rs | 3 + .../src/provider_context.rs | 115 ++++++++++++++++++ .../src/erc_8004_client.rs | 69 ++++------- 5 files changed, 171 insertions(+), 89 deletions(-) create mode 100644 crates/contract-clients-common/src/provider_context.rs 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/contract-clients-common/Cargo.toml b/crates/contract-clients-common/Cargo.toml index 4abd78b..42e75f7 100644 --- a/crates/contract-clients-common/Cargo.toml +++ b/crates/contract-clients-common/Cargo.toml @@ -6,6 +6,7 @@ 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/contract-clients-common/src/lib.rs b/crates/contract-clients-common/src/lib.rs index a974295..72f1fda 100644 --- a/crates/contract-clients-common/src/lib.rs +++ b/crates/contract-clients-common/src/lib.rs @@ -27,8 +27,11 @@ 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, 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..3c8f854 --- /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(0), + ..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/erc-8004-contract-clients/src/erc_8004_client.rs b/crates/erc-8004-contract-clients/src/erc_8004_client.rs index 2e9c915..c588fe2 100644 --- a/crates/erc-8004-contract-clients/src/erc_8004_client.rs +++ b/crates/erc-8004-contract-clients/src/erc_8004_client.rs @@ -1,44 +1,34 @@ use crate::{ContractConfig, IdentityRegistryClient, ValidationRegistryClient}; 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 ERC-8004 contract clients with a shared Alloy provider. #[derive(Clone)] pub struct Erc8004Client { - provider: DynProvider, - wallet: EthereumWallet, + ctx: ProviderContext, pub identity_registry: IdentityRegistryClient, pub validation_registry: ValidationRegistryClient, } impl Erc8004Client { 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 ws = WsConnect::new(ws_url); - 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(); + let ctx = ProviderContext::new(&config.rpc_url, &private_key).await?; + 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 identity_registry = IdentityRegistryClient::new( @@ -49,12 +39,11 @@ impl Erc8004Client { let validation_registry = ValidationRegistryClient::new( provider.clone(), config.validation_registry_contract_address, - tx_lock.clone(), + tx_lock, ); Ok(Self { - provider, - wallet, + ctx, identity_registry, validation_registry, }) @@ -62,36 +51,26 @@ impl Erc8004Client { /// 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 } /// Get the current block number pub async fn get_block_number(&self) -> anyhow::Result { - Ok(self.provider.get_block_number().await?) + self.ctx.get_block_number().await } } From fcbe69efccdc3102f57c33f67731589d995514d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Mon, 2 Feb 2026 22:01:13 +0100 Subject: [PATCH 07/17] feat: embedded validation responses on keeper --- .../src/validation_registry.rs | 55 ++++ docker-compose.yml | 6 +- keeper/Cargo.toml | 1 + keeper/src/args.rs | 18 ++ keeper/src/clients.rs | 12 + keeper/src/erc8004/events.rs | 259 ++++++++++++++++++ keeper/src/erc8004/mod.rs | 51 ++++ keeper/src/erc8004/responder.rs | 122 +++++++++ keeper/src/l2/events.rs | 49 +++- keeper/src/l2/supervisor.rs | 58 +++- keeper/src/main.rs | 14 +- keeper/src/metrics.rs | 43 +++ simulator/src/common.rs | 4 +- simulator/src/erc8004.rs | 26 +- 14 files changed, 698 insertions(+), 20 deletions(-) create mode 100644 keeper/src/erc8004/events.rs create mode 100644 keeper/src/erc8004/mod.rs create mode 100644 keeper/src/erc8004/responder.rs diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs index 4a34787..86eb653 100644 --- a/crates/erc-8004-contract-clients/src/validation_registry.rs +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -19,11 +19,40 @@ sol! { 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 { @@ -83,4 +112,30 @@ impl ValidationRegistryClient

{ ); 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 43db1b3..d11c40b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,10 @@ services: - L2_HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 - L2_JAILING_POLICY_ADDRESS=0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F + # ERC-8004 Keeper configuration + - ENABLE_ERC8004_KEEPER=true + - L2_VALIDATION_REGISTRY_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c + - RUST_LOG=info # NilCC Simulator - Submits HTXs to the contract simulator: @@ -97,7 +101,7 @@ services: - PRIVATE_KEY=0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a - IDENTITY_REGISTRY_CONTRACT_ADDRESS=0x959922bE3CAee4b8Cd9a407cc3ac1C251C2007B1 - VALIDATION_REGISTRY_CONTRACT_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c - - HEARTBEAT_MANAGER_ADDRESS=0x5FC8d32690cc91D4c39d9d3abcBD16989F875707 + - VALIDATOR_ADDRESS=0x90F79bf6EB2c4f870365E785982E1f101E93b906 - AGENT_URI=https://api.nilai.nillion.network/v1/health/ - RUST_LOG=info networks: diff --git a/keeper/Cargo.toml b/keeper/Cargo.toml index 4dd1f59..21ee092 100644 --- a/keeper/Cargo.toml +++ b/keeper/Cargo.toml @@ -17,3 +17,4 @@ 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..a07c42f 100644 --- a/keeper/src/clients.rs +++ b/keeper/src/clients.rs @@ -6,6 +6,7 @@ use alloy::{ signers::local::PrivateKeySigner, }; use blacklight_contract_clients::{HeartbeatManager, StakingOperators}; +use erc_8004_contract_clients::validation_registry::ValidationRegistryUpgradeable; pub type HeartbeatManagerInstance = HeartbeatManager::HeartbeatManagerInstance; pub type StakingOperatorsInstance = StakingOperators::StakingOperatorsInstance; @@ -14,6 +15,8 @@ pub type EmissionsControllerInstance = EmissionsController::EmissionsControllerInstance; pub type RewardPolicyInstance = RewardPolicy::RewardPolicyInstance; pub type ERC20Instance = Erc20::Erc20Instance; +pub type ValidationRegistryInstance = + ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; async fn connect_ws( rpc_url: &str, @@ -42,6 +45,7 @@ pub struct L2KeeperClient { heartbeat_manager: HeartbeatManagerInstance, staking_operators: StakingOperatorsInstance, jailing_policy: Option, + validation_registry: Option, provider: DynProvider, wallet: EthereumWallet, } @@ -52,6 +56,7 @@ 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?; @@ -61,11 +66,14 @@ impl L2KeeperClient { 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| ValidationRegistryInstance::new(addr, provider.clone())); Ok(Self { heartbeat_manager, staking_operators, jailing_policy, + validation_registry, provider, wallet, }) @@ -83,6 +91,10 @@ impl L2KeeperClient { self.jailing_policy.as_ref() } + pub fn validation_registry(&self) -> Option<&ValidationRegistryInstance> { + self.validation_registry.as_ref() + } + pub fn reward_policy(&self, address: Address) -> RewardPolicyInstance { RewardPolicyInstance::new(address, self.provider.clone()) } diff --git a/keeper/src/erc8004/events.rs b/keeper/src/erc8004/events.rs new file mode 100644 index 0000000..8b11109 --- /dev/null +++ b/keeper/src/erc8004/events.rs @@ -0,0 +1,259 @@ +use crate::{erc8004::ValidationRequestInfo, 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}; + +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().erc8004.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.pending_validations.insert(heartbeat_key, info); + metrics::get() + .erc8004 + .set_requests_tracked(guard.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) { + info.outcome = Some(outcome); + info!( + heartbeat_key = %heartbeat_key, + outcome, + 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..776d4e5 --- /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 outcome from HeartbeatManager (0=invalid, 50=inconclusive, 100=valid) + 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..b56b9e5 --- /dev/null +++ b/keeper/src/erc8004/responder.rs @@ -0,0 +1,122 @@ +use crate::{erc8004::Erc8004State, metrics}; +use alloy::hex; +use alloy::primitives::B256; +use alloy::providers::Provider; +use anyhow::Context; +use erc_8004_contract_clients::validation_registry::ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; +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: ValidationRegistryUpgradeableInstance

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

{ + pub fn new( + registry: ValidationRegistryUpgradeableInstance

, + 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; + let pending_count = state.pending_validations.len(); + info!( + pending_count, + "Processing ERC-8004 responses: checking tracked validations" + ); + + state + .pending_validations + .iter() + .filter(|(_, info)| info.outcome.is_some() && !info.response_submitted) + .map(|(key, info)| (*key, info.request_hash, info.outcome.unwrap())) + .collect() + }; + + info!( + ready_count = jobs.len(), + "Found 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.pending_validations.get_mut(&heartbeat_key) { + info.response_submitted = true; + } + metrics::get().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 pending = self + .registry + .validationResponse( + request_hash, + outcome, + String::new(), // responseURI - empty + B256::ZERO, // responseHash - zero + "heartbeat".to_string(), // tag + ) + .send() + .await + .map_err(|e| { + error!( + request_hash = %request_hash, + outcome, + error = %e, + "validationResponse transaction failed" + ); + anyhow::anyhow!("Failed to send validationResponse: {e}") + })?; + + let receipt = pending + .get_receipt() + .await + .context("Failed to get transaction receipt")?; + + Ok(receipt.transaction_hash) + } +} diff --git a/keeper/src/l2/events.rs b/keeper/src/l2/events.rs index 75e74ff..bdde2f1 100644 --- a/keeper/src/l2/events.rs +++ b/keeper/src/l2/events.rs @@ -1,5 +1,6 @@ use crate::{ clients::HeartbeatManagerInstance, + erc8004::{Erc8004State, 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}; @@ -105,6 +107,7 @@ impl EventListener { self, from_block: u64, state: Arc>, + erc8004_state: Arc>, ) -> anyhow::Result<()> { let heartbeat_enqueued = self.subscribe(from_block).await?; let round_started = self.subscribe(from_block).await?; @@ -116,10 +119,15 @@ impl EventListener { heartbeat_enqueued, state.clone(), )); - tokio::spawn(Self::process_round_started(round_started, state.clone())); + tokio::spawn(Self::process_round_started( + round_started, + state.clone(), + erc8004_state.clone(), + )); tokio::spawn(Self::process_round_finalized( round_finalized, state.clone(), + erc8004_state, )); tokio::spawn(Self::process_rewards_distributed( rewards_distributed, @@ -198,6 +206,7 @@ impl EventListener { async fn process_round_started( events: impl Stream, state: Arc>, + erc8004_state: Arc>, ) { let mut events = pin!(events); while let Some(event) = events.next().await { @@ -220,12 +229,45 @@ impl EventListener { members = entry.members.len(), "Round started" ); + drop(guard); + + // Detect ERC-8004 HTXs and track them with HeartbeatManager's heartbeat_key + if let Ok(erc8004_htx) = Erc8004Htx::try_decode(&event.rawHTX) { + let mut erc8004_guard = erc8004_state.lock().await; + // Only add if not already tracked (first round) + if event.round == 1 + && !erc8004_guard + .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, + ); + erc8004_guard + .pending_validations + .insert(event.heartbeatKey, info); + metrics::get() + .erc8004 + .set_requests_tracked(erc8004_guard.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" + ); + } + } } } async fn process_round_finalized( events: impl Stream, state: Arc>, + erc8004_state: Arc>, ) { let mut events = pin!(events); while let Some(event) = events.next().await { @@ -242,6 +284,11 @@ impl EventListener { outcome = event.outcome, "Round finalized" ); + drop(guard); + + // Notify ERC-8004 state about the round finalization + let mut erc8004_guard = erc8004_state.lock().await; + on_round_finalized(&mut erc8004_guard, event.heartbeatKey, event.outcome); } } diff --git a/keeper/src/l2/supervisor.rs b/keeper/src/l2/supervisor.rs index 1b6930a..5b58369 100644 --- a/keeper/src/l2/supervisor.rs +++ b/keeper/src/l2/supervisor.rs @@ -1,6 +1,7 @@ use crate::{ args::KeeperConfig, clients::L2KeeperClient, + erc8004::{Erc8004State, events::Erc8004EventListener, responder::ValidationResponder}, l2::{ KeeperState, escalator::RoundEscalator, events::EventListener, jailing::Jailer, rewards::RewardsDistributor, @@ -11,30 +12,38 @@ use alloy::{eips::BlockId, providers::Provider}; use anyhow::Context; use std::sync::Arc; use tokio::{sync::Mutex, time::interval}; -use tracing::error; +use tracing::{error, info, trace}; pub struct L2Supervisor { client: Arc, state: Arc>, + erc8004_state: Arc>, 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_state = Arc::new(Mutex::new(Erc8004State::default())); + let erc8004_enabled = + config.enable_erc8004 && config.l2_validation_registry_address.is_some(); Ok(Self { client, state, + erc8004_state, jailer, rewards_distributor, round_escalator, + erc8004_enabled, }) } @@ -56,15 +65,50 @@ impl L2Supervisor { // Now spawn to process any new blocks after latest_block event_listener - .spawn(latest_block.saturating_add(1), self.state.clone()) + .spawn( + latest_block.saturating_add(1), + self.state.clone(), + self.erc8004_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 { + if let Some(registry) = self.client.validation_registry() { + let erc8004_listener = Erc8004EventListener::new(registry.clone()); + erc8004_listener + .process_historical_events( + from_block, + latest_block, + &mut *self.erc8004_state.lock().await, + ) + .await + .context("Failed to process historical ERC-8004 events")?; + + erc8004_listener + .spawn(latest_block.saturating_add(1), self.erc8004_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 + let erc8004_responder = if self.erc8004_enabled { + self.client.validation_registry().map(|registry| { + ValidationResponder::new(registry.clone(), self.erc8004_state.clone()) + }) + } else { + None + }; + let mut ticker = interval(config.tick_interval); loop { ticker.tick().await; @@ -98,6 +142,14 @@ impl L2Supervisor { self.process_rounds(block_timestamp).await; + // Process ERC-8004 validation responses + if let Some(ref responder) = erc8004_responder { + trace!("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..de8a217 100644 --- a/keeper/src/metrics.rs +++ b/keeper/src/metrics.rs @@ -17,6 +17,7 @@ pub(crate) fn get() -> &'static Metrics { pub(crate) struct Metrics { pub(crate) l1: L1Metrics, pub(crate) l2: L2Metrics, + pub(crate) erc8004: Erc8004Metrics, // A private guard to prevent this type from being constructed outside of this module. _private: (), } @@ -25,9 +26,11 @@ impl Metrics { fn new(meter: &Meter) -> Self { let l1 = L1Metrics::new(meter); let l2 = L2Metrics::new(meter); + let erc8004 = Erc8004Metrics::new(meter); Self { l1, l2, + erc8004, _private: (), } } @@ -249,3 +252,43 @@ impl L2EthMetrics { self.funds.record(amount.into(), &[]); } } + +pub(crate) struct Erc8004Metrics { + events_received: Counter, + requests_tracked: Gauge, + responses_submitted: Counter, +} + +impl Erc8004Metrics { + fn new(meter: &Meter) -> Self { + let events_received = meter + .u64_counter("blacklight.keeper.erc8004.events.received") + .with_description("Total ERC-8004 events received") + .build(); + let requests_tracked = meter + .u64_gauge("blacklight.keeper.erc8004.requests_tracked") + .with_description("Number of ERC-8004 validation requests currently tracked") + .build(); + let responses_submitted = meter + .u64_counter("blacklight.keeper.erc8004.responses_submitted") + .with_description("Total ERC-8004 validation responses submitted") + .build(); + Self { + events_received, + requests_tracked, + responses_submitted, + } + } + + pub(crate) fn inc_events_received(&self, name: &'static str) { + self.events_received.add(1, &[KeyValue::new("name", name)]); + } + + 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/simulator/src/common.rs b/simulator/src/common.rs index 547c391..33a47c3 100644 --- a/simulator/src/common.rs +++ b/simulator/src/common.rs @@ -8,10 +8,10 @@ use tracing::error; /// Default slot interval in milliseconds. #[cfg(debug_assertions)] -pub const DEFAULT_SLOT_MS: u64 = 3000; +pub const DEFAULT_SLOT_MS: u64 = 15000; #[cfg(not(debug_assertions))] -pub const DEFAULT_SLOT_MS: u64 = 5000; +pub const DEFAULT_SLOT_MS: u64 = 15000; pub const MAX_RETRIES: u32 = 3; pub const RETRY_DELAY_MS: u64 = 500; diff --git a/simulator/src/erc8004.rs b/simulator/src/erc8004.rs index f0f22a9..86ffb81 100644 --- a/simulator/src/erc8004.rs +++ b/simulator/src/erc8004.rs @@ -33,9 +33,10 @@ pub struct Erc8004Args { #[arg(long, env = "AGENT_URI")] pub agent_uri: Option, - /// HeartbeatManager contract address to submit validation requests to - #[arg(long, env = "HEARTBEAT_MANAGER_ADDRESS")] - pub heartbeat_manager_address: 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)] @@ -45,7 +46,8 @@ pub struct Erc8004Config { pub validation_registry_contract_address: Address, pub private_key: String, pub agent_uri: String, - pub heartbeat_manager_address: Address, + /// The validator address authorized to submit responses (typically the keeper) + pub validator_address: Address, pub slot_ms: u64, } @@ -82,14 +84,14 @@ impl Erc8004Config { .or_else(|| state_file.load_value("AGENT_URI")) .unwrap_or_else(|| "https://example.com/agent".to_string()); - let heartbeat_manager_address = args - .heartbeat_manager_address - .or_else(|| state_file.load_value("HEARTBEAT_MANAGER_ADDRESS")) - .unwrap_or_else(|| "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707".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}" + "Loaded Erc8004Config: rpc_url={rpc_url}, identity_registry={identity_registry_contract_address}, validation_registry={validation_registry_contract_address}, validator={validator_address}" ); Ok(Self { @@ -98,7 +100,7 @@ impl Erc8004Config { validation_registry_contract_address, private_key, agent_uri, - heartbeat_manager_address, + validator_address, slot_ms: DEFAULT_SLOT_MS, }) } @@ -177,7 +179,7 @@ impl Erc8004Simulator { info!( slot, agent_id = %agent_id, - heartbeat_manager = %config.heartbeat_manager_address, + validator = %config.validator_address, snapshot_id = snapshot_id, request_uri = %request_uri, "Submitting validation request" @@ -189,7 +191,7 @@ impl Erc8004Simulator { let tx_hash = client .validation_registry .validation_request( - config.heartbeat_manager_address, + config.validator_address, agent_id, request_uri, request_hash, From fc0722aec7d1bf58519ab6e1bf0aec8ea627bf72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Wed, 4 Feb 2026 16:41:47 +0100 Subject: [PATCH 08/17] fix: improved code structure --- docker-compose.yml | 7 ++-- keeper/src/erc8004/events.rs | 31 +++++++++++++---- keeper/src/erc8004/mod.rs | 2 +- keeper/src/erc8004/responder.rs | 11 +++--- keeper/src/l2/events.rs | 27 +++++---------- keeper/src/l2/mod.rs | 2 ++ keeper/src/l2/supervisor.rs | 61 ++++++++++++++++----------------- 7 files changed, 76 insertions(+), 65 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d11c40b..5182681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ services: # Anvil - Local Ethereum testnet anvil: - image: nilanvil:latest + image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-dfb9847 container_name: blacklight-anvil ports: - "8545:8545" @@ -32,9 +32,10 @@ services: - 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=0x2279B7A0a67DB372996a5FaB50D91eAA73d2eBe6 - - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0165878A594ca255338adfa4d48449f69242Eb8F + - L2_JAILING_POLICY_ADDRESS=0x0000000000000000000000000000000000000000 + - L1_EMISSIONS_CONTROLLER_ADDRESS=0x0000000000000000000000000000000000000000 # ERC-8004 Keeper configuration - ENABLE_ERC8004_KEEPER=true - L2_VALIDATION_REGISTRY_ADDRESS=0x3Aa5ebB10DC797CAC828524e59A333d0A371443c diff --git a/keeper/src/erc8004/events.rs b/keeper/src/erc8004/events.rs index 8b11109..9f71866 100644 --- a/keeper/src/erc8004/events.rs +++ b/keeper/src/erc8004/events.rs @@ -1,4 +1,4 @@ -use crate::{erc8004::ValidationRequestInfo, metrics}; +use crate::{erc8004::ValidationRequestInfo, l2::KeeperState, metrics}; use alloy::{ primitives::B256, providers::Provider, @@ -12,7 +12,7 @@ use erc_8004_contract_clients::validation_registry::{ use futures_util::{Stream, StreamExt}; use std::{pin::pin, sync::Arc}; use tokio::sync::Mutex; -use tracing::{debug, error, info}; +use tracing::{debug, error, info, warn}; use super::Erc8004State; @@ -69,7 +69,7 @@ impl Erc8004EventListener

{ pub async fn spawn( self, from_block: u64, - state: Arc>, + state: Arc>, ) -> anyhow::Result<()> { let validation_request = self.subscribe::(from_block).await?; tokio::spawn(Self::process_validation_requests(validation_request, state)); @@ -122,7 +122,7 @@ impl Erc8004EventListener

{ async fn process_validation_requests( events: impl Stream, - state: Arc>, + state: Arc>, ) { let mut events = pin!(events); while let Some(event) = events.next().await { @@ -141,10 +141,13 @@ impl Erc8004EventListener

{ ); let mut guard = state.lock().await; - guard.pending_validations.insert(heartbeat_key, info); + guard + .erc8004 + .pending_validations + .insert(heartbeat_key, info); metrics::get() .erc8004 - .set_requests_tracked(guard.pending_validations.len() as u64); + .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); info!( heartbeat_key = ?heartbeat_key, @@ -181,10 +184,24 @@ pub fn compute_heartbeat_key( /// 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) { - info.outcome = Some(outcome); + let response = match outcome { + 0 => 50, // inconclusive + 1 => 100, // valid + 2 => 0, // invalid + 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" ); diff --git a/keeper/src/erc8004/mod.rs b/keeper/src/erc8004/mod.rs index 776d4e5..de84fdd 100644 --- a/keeper/src/erc8004/mod.rs +++ b/keeper/src/erc8004/mod.rs @@ -26,7 +26,7 @@ pub struct ValidationRequestInfo { pub request_uri: String, /// The request hash (unique identifier for the validation) pub request_hash: B256, - /// The outcome from HeartbeatManager (0=invalid, 50=inconclusive, 100=valid) + /// 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, diff --git a/keeper/src/erc8004/responder.rs b/keeper/src/erc8004/responder.rs index b56b9e5..7e5f305 100644 --- a/keeper/src/erc8004/responder.rs +++ b/keeper/src/erc8004/responder.rs @@ -1,4 +1,4 @@ -use crate::{erc8004::Erc8004State, metrics}; +use crate::{l2::KeeperState, metrics}; use alloy::hex; use alloy::primitives::B256; use alloy::providers::Provider; @@ -11,13 +11,13 @@ use tracing::{debug, error, info}; /// Submits validation responses for finalized ERC-8004 validation rounds. pub struct ValidationResponder { registry: ValidationRegistryUpgradeableInstance

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

{ pub fn new( registry: ValidationRegistryUpgradeableInstance

, - state: Arc>, + state: Arc>, ) -> Self { Self { registry, state } } @@ -30,13 +30,14 @@ impl ValidationResponder

{ // Collect jobs to process outside the lock let jobs: Vec<_> = { let state = self.state.lock().await; - let pending_count = state.pending_validations.len(); + let pending_count = state.erc8004.pending_validations.len(); info!( pending_count, "Processing ERC-8004 responses: checking tracked validations" ); state + .erc8004 .pending_validations .iter() .filter(|(_, info)| info.outcome.is_some() && !info.response_submitted) @@ -66,7 +67,7 @@ impl ValidationResponder

{ Ok(tx_hash) => { // Mark as submitted let mut state = self.state.lock().await; - if let Some(info) = state.pending_validations.get_mut(&heartbeat_key) { + if let Some(info) = state.erc8004.pending_validations.get_mut(&heartbeat_key) { info.response_submitted = true; } metrics::get().erc8004.inc_responses_submitted(); diff --git a/keeper/src/l2/events.rs b/keeper/src/l2/events.rs index bdde2f1..8eb6d82 100644 --- a/keeper/src/l2/events.rs +++ b/keeper/src/l2/events.rs @@ -1,6 +1,6 @@ use crate::{ clients::HeartbeatManagerInstance, - erc8004::{Erc8004State, ValidationRequestInfo, events::on_round_finalized}, + erc8004::{ValidationRequestInfo, events::on_round_finalized}, l2::{KeeperState, RoundKey}, metrics, }; @@ -75,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 { @@ -107,7 +108,6 @@ impl EventListener { self, from_block: u64, state: Arc>, - erc8004_state: Arc>, ) -> anyhow::Result<()> { let heartbeat_enqueued = self.subscribe(from_block).await?; let round_started = self.subscribe(from_block).await?; @@ -119,15 +119,10 @@ impl EventListener { heartbeat_enqueued, state.clone(), )); - tokio::spawn(Self::process_round_started( - round_started, - state.clone(), - erc8004_state.clone(), - )); + tokio::spawn(Self::process_round_started(round_started, state.clone())); tokio::spawn(Self::process_round_finalized( round_finalized, state.clone(), - erc8004_state, )); tokio::spawn(Self::process_rewards_distributed( rewards_distributed, @@ -206,7 +201,6 @@ impl EventListener { async fn process_round_started( events: impl Stream, state: Arc>, - erc8004_state: Arc>, ) { let mut events = pin!(events); while let Some(event) = events.next().await { @@ -229,14 +223,13 @@ impl EventListener { members = entry.members.len(), "Round started" ); - drop(guard); // Detect ERC-8004 HTXs and track them with HeartbeatManager's heartbeat_key if let Ok(erc8004_htx) = Erc8004Htx::try_decode(&event.rawHTX) { - let mut erc8004_guard = erc8004_state.lock().await; // Only add if not already tracked (first round) if event.round == 1 - && !erc8004_guard + && !guard + .erc8004 .pending_validations .contains_key(&event.heartbeatKey) { @@ -246,12 +239,13 @@ impl EventListener { erc8004_htx.request_uri.clone(), erc8004_htx.request_hash, ); - erc8004_guard + guard + .erc8004 .pending_validations .insert(event.heartbeatKey, info); metrics::get() .erc8004 - .set_requests_tracked(erc8004_guard.pending_validations.len() as u64); + .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); info!( heartbeat_key = %event.heartbeatKey, validator = %erc8004_htx.validator_address, @@ -267,7 +261,6 @@ impl EventListener { async fn process_round_finalized( events: impl Stream, state: Arc>, - erc8004_state: Arc>, ) { let mut events = pin!(events); while let Some(event) = events.next().await { @@ -284,11 +277,9 @@ impl EventListener { outcome = event.outcome, "Round finalized" ); - drop(guard); // Notify ERC-8004 state about the round finalization - let mut erc8004_guard = erc8004_state.lock().await; - on_round_finalized(&mut erc8004_guard, event.heartbeatKey, event.outcome); + on_round_finalized(&mut guard.erc8004, event.heartbeatKey, event.outcome); } } 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/supervisor.rs b/keeper/src/l2/supervisor.rs index 5b58369..99107f5 100644 --- a/keeper/src/l2/supervisor.rs +++ b/keeper/src/l2/supervisor.rs @@ -1,7 +1,7 @@ use crate::{ args::KeeperConfig, clients::L2KeeperClient, - erc8004::{Erc8004State, events::Erc8004EventListener, responder::ValidationResponder}, + erc8004::{events::Erc8004EventListener, responder::ValidationResponder}, l2::{ KeeperState, escalator::RoundEscalator, events::EventListener, jailing::Jailer, rewards::RewardsDistributor, @@ -17,7 +17,6 @@ use tracing::{error, info, trace}; pub struct L2Supervisor { client: Arc, state: Arc>, - erc8004_state: Arc>, jailer: Jailer, rewards_distributor: RewardsDistributor, round_escalator: RoundEscalator, @@ -33,13 +32,11 @@ impl L2Supervisor { 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_state = Arc::new(Mutex::new(Erc8004State::default())); let erc8004_enabled = config.enable_erc8004 && config.l2_validation_registry_address.is_some(); Ok(Self { client, state, - erc8004_state, jailer, rewards_distributor, round_escalator, @@ -56,6 +53,21 @@ 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 erc8004_listener = Erc8004EventListener::new(registry.clone()); + 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 @@ -65,34 +77,21 @@ impl L2Supervisor { // Now spawn to process any new blocks after latest_block event_listener - .spawn( - latest_block.saturating_add(1), - self.state.clone(), - self.erc8004_state.clone(), - ) + .spawn(latest_block.saturating_add(1), self.state.clone()) .await .context("Failed to spawn event listener")?; // Spawn ERC-8004 event listener if enabled - if self.erc8004_enabled { - if let Some(registry) = self.client.validation_registry() { - let erc8004_listener = Erc8004EventListener::new(registry.clone()); - erc8004_listener - .process_historical_events( - from_block, - latest_block, - &mut *self.erc8004_state.lock().await, - ) - .await - .context("Failed to process historical ERC-8004 events")?; - - erc8004_listener - .spawn(latest_block.saturating_add(1), self.erc8004_state.clone()) - .await - .context("Failed to spawn ERC-8004 event listener")?; - - info!("ERC-8004 event listener spawned"); - } + if self.erc8004_enabled + && let Some(registry) = self.client.validation_registry() + { + let erc8004_listener = Erc8004EventListener::new(registry.clone()); + 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)); @@ -102,9 +101,9 @@ impl L2Supervisor { async fn run(mut self, config: KeeperConfig) { // Create ERC-8004 responder if enabled let erc8004_responder = if self.erc8004_enabled { - self.client.validation_registry().map(|registry| { - ValidationResponder::new(registry.clone(), self.erc8004_state.clone()) - }) + self.client + .validation_registry() + .map(|registry| ValidationResponder::new(registry.clone(), self.state.clone())) } else { None }; From cd5bad687cea641097df10526c881fd35cefd406 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 5 Feb 2026 09:47:41 +0100 Subject: [PATCH 09/17] feat: improved logging and codebase --- .../src/provider_context.rs | 2 +- keeper/src/clients.rs | 81 +++++++------------ keeper/src/erc8004/responder.rs | 49 +++-------- keeper/src/l2/escalator.rs | 13 ++- keeper/src/l2/rewards.rs | 3 +- keeper/src/l2/supervisor.rs | 23 ++++-- 6 files changed, 74 insertions(+), 97 deletions(-) diff --git a/crates/contract-clients-common/src/provider_context.rs b/crates/contract-clients-common/src/provider_context.rs index 3c8f854..4384573 100644 --- a/crates/contract-clients-common/src/provider_context.rs +++ b/crates/contract-clients-common/src/provider_context.rs @@ -100,7 +100,7 @@ impl ProviderContext { let tx = TransactionRequest { to: Some(TxKind::Call(to)), value: Some(amount), - max_priority_fee_per_gas: Some(0), + max_priority_fee_per_gas: Some(1), ..Default::default() }; diff --git a/keeper/src/clients.rs b/keeper/src/clients.rs index a07c42f..b948832 100644 --- a/keeper/src/clients.rs +++ b/keeper/src/clients.rs @@ -1,12 +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; @@ -18,36 +20,13 @@ pub type ERC20Instance = Erc20::Erc20Instance; pub type ValidationRegistryInstance = ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; -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)) -} - /// WebSocket-based client for L2 keeper duties (heartbeat rounds + jailing) pub struct L2KeeperClient { + ctx: ProviderContext, heartbeat_manager: HeartbeatManagerInstance, staking_operators: StakingOperatorsInstance, jailing_policy: Option, - validation_registry: Option, - provider: DynProvider, - wallet: EthereumWallet, + validation_registry: Option>, } impl L2KeeperClient { @@ -59,7 +38,10 @@ impl L2KeeperClient { 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 = @@ -67,15 +49,14 @@ impl L2KeeperClient { let jailing_policy = jailing_policy_address.map(|addr| JailingPolicyInstance::new(addr, provider.clone())); let validation_registry = validation_registry_address - .map(|addr| ValidationRegistryInstance::new(addr, provider.clone())); + .map(|addr| ValidationRegistryClient::new(provider.clone(), addr, tx_lock)); Ok(Self { + ctx, heartbeat_manager, staking_operators, jailing_policy, validation_registry, - provider, - wallet, }) } @@ -91,36 +72,40 @@ impl L2KeeperClient { self.jailing_policy.as_ref() } - pub fn validation_registry(&self) -> Option<&ValidationRegistryInstance> { + 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 { @@ -129,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 { @@ -143,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/responder.rs b/keeper/src/erc8004/responder.rs index 7e5f305..ea16b57 100644 --- a/keeper/src/erc8004/responder.rs +++ b/keeper/src/erc8004/responder.rs @@ -2,23 +2,19 @@ use crate::{l2::KeeperState, metrics}; use alloy::hex; use alloy::primitives::B256; use alloy::providers::Provider; -use anyhow::Context; -use erc_8004_contract_clients::validation_registry::ValidationRegistryUpgradeable::ValidationRegistryUpgradeableInstance; +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: ValidationRegistryUpgradeableInstance

, + registry: ValidationRegistryClient

, state: Arc>, } impl ValidationResponder

{ - pub fn new( - registry: ValidationRegistryUpgradeableInstance

, - state: Arc>, - ) -> Self { + pub fn new(registry: ValidationRegistryClient

, state: Arc>) -> Self { Self { registry, state } } @@ -30,12 +26,6 @@ impl ValidationResponder

{ // Collect jobs to process outside the lock let jobs: Vec<_> = { let state = self.state.lock().await; - let pending_count = state.erc8004.pending_validations.len(); - info!( - pending_count, - "Processing ERC-8004 responses: checking tracked validations" - ); - state .erc8004 .pending_validations @@ -45,10 +35,12 @@ impl ValidationResponder

{ .collect() }; - info!( - ready_count = jobs.len(), - "Found validations ready for response submission" - ); + if !jobs.is_empty() { + info!( + ready_count = jobs.len(), + "ERC-8004 validations ready for response submission" + ); + } if jobs.is_empty() { debug!( @@ -92,32 +84,17 @@ impl ValidationResponder

{ } async fn submit_response(&self, request_hash: B256, outcome: u8) -> anyhow::Result { - let pending = self + let tx_hash = self .registry - .validationResponse( + .validation_response( request_hash, outcome, String::new(), // responseURI - empty B256::ZERO, // responseHash - zero "heartbeat".to_string(), // tag ) - .send() - .await - .map_err(|e| { - error!( - request_hash = %request_hash, - outcome, - error = %e, - "validationResponse transaction failed" - ); - anyhow::anyhow!("Failed to send validationResponse: {e}") - })?; - - let receipt = pending - .get_receipt() - .await - .context("Failed to get transaction receipt")?; + .await?; - Ok(receipt.transaction_hash) + Ok(tx_hash) } } diff --git a/keeper/src/l2/escalator.rs b/keeper/src/l2/escalator.rs index d7dbb52..1f44fb9 100644 --- a/keeper/src/l2/escalator.rs +++ b/keeper/src/l2/escalator.rs @@ -3,7 +3,7 @@ use alloy::primitives::{B256, Bytes}; 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; @@ -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/rewards.rs b/keeper/src/l2/rewards.rs index 9bcee33..fb7b89d 100644 --- a/keeper/src/l2/rewards.rs +++ b/keeper/src/l2/rewards.rs @@ -46,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 99107f5..32cd487 100644 --- a/keeper/src/l2/supervisor.rs +++ b/keeper/src/l2/supervisor.rs @@ -1,6 +1,6 @@ use crate::{ args::KeeperConfig, - clients::L2KeeperClient, + clients::{L2KeeperClient, ValidationRegistryInstance}, erc8004::{events::Erc8004EventListener, responder::ValidationResponder}, l2::{ KeeperState, escalator::RoundEscalator, events::EventListener, jailing::Jailer, @@ -12,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, info, trace}; +use tracing::{debug, error, info}; pub struct L2Supervisor { client: Arc, @@ -57,7 +57,9 @@ impl L2Supervisor { if self.erc8004_enabled && let Some(registry) = self.client.validation_registry() { - let erc8004_listener = Erc8004EventListener::new(registry.clone()); + let raw_registry = + ValidationRegistryInstance::new(registry.address(), self.client.provider()); + let erc8004_listener = Erc8004EventListener::new(raw_registry); erc8004_listener .process_historical_events( from_block, @@ -85,7 +87,9 @@ impl L2Supervisor { if self.erc8004_enabled && let Some(registry) = self.client.validation_registry() { - let erc8004_listener = Erc8004EventListener::new(registry.clone()); + 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 @@ -99,14 +103,19 @@ impl L2Supervisor { } async fn run(mut self, config: KeeperConfig) { - // Create ERC-8004 responder if enabled + // Create ERC-8004 responder if enabled (uses shared tx_lock via ValidationRegistryClient) let erc8004_responder = if self.erc8004_enabled { self.client .validation_registry() - .map(|registry| ValidationResponder::new(registry.clone(), self.state.clone())) + .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 { @@ -143,7 +152,7 @@ impl L2Supervisor { // Process ERC-8004 validation responses if let Some(ref responder) = erc8004_responder { - trace!("Tick: processing ERC-8004 validation responses"); + debug!("Tick: processing ERC-8004 validation responses"); if let Err(e) = responder.process_responses().await { error!("Failed to process ERC-8004 validation responses: {e}"); } From 9e982490886e2a20f431bddf7fc8d1149734ed80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 29 Jan 2026 10:49:43 +0100 Subject: [PATCH 10/17] fix: replace notify with cancellation signal --- blacklight-node/Cargo.toml | 1 + blacklight-node/src/main.rs | 40 ++++++++++++++++++------------------- 2 files changed, 21 insertions(+), 20 deletions(-) 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 d1cded5..3eee2f5 100644 --- a/blacklight-node/src/main.rs +++ b/blacklight-node/src/main.rs @@ -11,7 +11,7 @@ use clap::Parser; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Duration; -use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; use verification::HtxVerifier; @@ -35,7 +35,7 @@ use version::{VERSION, validate_node_version}; // ============================================================================ /// Setup shutdown signal handler (Ctrl+C / SIGTERM) -async fn setup_shutdown_handler(shutdown_notify: Arc) { +async fn setup_shutdown_handler(shutdown_token: CancellationToken) { #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; @@ -54,7 +54,7 @@ async fn setup_shutdown_handler(shutdown_notify: Arc) { } } - shutdown_notify.notify_waiters(); + shutdown_token.cancel(); } #[cfg(not(unix))] @@ -62,7 +62,7 @@ async fn setup_shutdown_handler(shutdown_notify: Arc) { match tokio::signal::ctrl_c().await { Ok(()) => { info!("Shutdown signal received (Ctrl+C)"); - shutdown_notify.notify_waiters(); + shutdown_token.cancel(); } Err(err) => { error!(error = %err, "Failed to listen for shutdown signal"); @@ -101,7 +101,7 @@ async fn process_htx_assignment( event: RoundStartedEvent, verifier: &HtxVerifier, verified_counter: Arc, - shutdown_notify: Arc, + shutdown_token: CancellationToken, node_address: Address, ) -> Result<()> { let htx_id = event.heartbeatKey; @@ -196,7 +196,7 @@ async fn process_htx_assignment( min_required = %format_ether(MIN_ETH_BALANCE), "⚠️ ETH balance below minimum threshold. Initiating shutdown..." ); - shutdown_notify.notify_waiters(); + shutdown_token.cancel(); return Err(anyhow::anyhow!("Insufficient ETH balance")); } } @@ -220,7 +220,7 @@ async fn process_assignment_backlog( node_address: Address, verifier: &HtxVerifier, verified_counter: Arc, - shutdown_notify: Arc, + shutdown_token: CancellationToken, ) -> Result<()> { info!("Checking for pending assignments from before connection"); @@ -253,7 +253,7 @@ async fn process_assignment_backlog( let client_clone = client.clone(); let verifier = verifier.clone(); let counter = verified_counter.clone(); - let shutdown_clone = shutdown_notify.clone(); + let shutdown_clone = shutdown_token.clone(); tokio::spawn(async move { if let Err(e) = process_htx_assignment( client_clone, @@ -308,7 +308,7 @@ async fn register_node_if_needed(client: &BlacklightClient, node_address: Addres /// Create a WebSocket client with exponential backoff retry logic async fn create_client_with_retry( config: &NodeConfig, - shutdown_notify: &Arc, + shutdown_token: &CancellationToken, ) -> Result { let mut reconnect_delay = INITIAL_RECONNECT_DELAY; let max_reconnect_delay = MAX_RECONNECT_DELAY; @@ -338,7 +338,7 @@ async fn create_client_with_retry( _ = tokio::time::sleep(reconnect_delay) => { reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay); } - _ = shutdown_notify.notified() => { + _ = shutdown_token.cancelled() => { return Err(anyhow::anyhow!("Shutdown signal received during connection retry")); } } @@ -355,13 +355,13 @@ async fn create_client_with_retry( async fn run_event_listener( client: Arc, node_address: Address, - shutdown_notify: Arc, + shutdown_token: CancellationToken, 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 shutdown_for_callback = shutdown_token.clone(); let manager = Arc::new(client.manager.clone()); let listen_future = manager.listen_htx_assigned_for_node(node_address, move |event| { @@ -408,7 +408,7 @@ async fn run_event_listener( result?; Ok(()) }, - _ = shutdown_notify.notified() => { + _ = shutdown_token.cancelled() => { info!("Shutdown signal received during event listening"); Err(anyhow::anyhow!("Shutdown requested")) } @@ -503,10 +503,10 @@ async fn main() -> Result<()> { 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(); + let shutdown_token = CancellationToken::new(); + let shutdown_token_clone = shutdown_token.clone(); tokio::spawn(async move { - setup_shutdown_handler(shutdown_notify_clone).await; + setup_shutdown_handler(shutdown_token_clone).await; }); // Counter for verified HTXs (for status reporting) @@ -521,7 +521,7 @@ async fn main() -> Result<()> { info!("Starting WebSocket event listener with auto-reconnection"); // Create client with retry logic - let client = match create_client_with_retry(&config, &shutdown_notify).await { + let client = match create_client_with_retry(&config, &shutdown_token).await { Ok(client) => client, Err(_) => break, // Shutdown requested or unrecoverable error }; @@ -545,7 +545,7 @@ async fn main() -> Result<()> { current_address, &verifier, verified_counter.clone(), - shutdown_notify.clone(), + shutdown_token.clone(), ) .await { @@ -556,7 +556,7 @@ async fn main() -> Result<()> { match run_event_listener( client_arc, current_address, - shutdown_notify.clone(), + shutdown_token.clone(), &verifier, verified_counter.clone(), ) @@ -578,7 +578,7 @@ async fn main() -> Result<()> { _ = tokio::time::sleep(reconnect_delay) => { reconnect_delay = std::cmp::min(reconnect_delay * 2, max_reconnect_delay); } - _ = shutdown_notify.notified() => { + _ = shutdown_token.cancelled() => { break; // Shutdown requested } } From 96c595d0dc46c542bef79fe4468bc66d8c874c3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Thu, 29 Jan 2026 11:58:20 +0100 Subject: [PATCH 11/17] chore: refactored and simplified blacklight-node code --- blacklight-node/src/main.rs | 562 +----------------- blacklight-node/src/shutdown.rs | 55 ++ blacklight-node/src/supervisor/events.rs | 37 ++ blacklight-node/src/supervisor/htx.rs | 251 ++++++++ blacklight-node/src/supervisor/mod.rs | 222 +++++++ blacklight-node/src/supervisor/status.rs | 49 ++ .../src/{ => supervisor}/version.rs | 0 7 files changed, 627 insertions(+), 549 deletions(-) create mode 100644 blacklight-node/src/shutdown.rs create mode 100644 blacklight-node/src/supervisor/events.rs create mode 100644 blacklight-node/src/supervisor/htx.rs create mode 100644 blacklight-node/src/supervisor/mod.rs create mode 100644 blacklight-node/src/supervisor/status.rs rename blacklight-node/src/{ => supervisor}/version.rs (100%) diff --git a/blacklight-node/src/main.rs b/blacklight-node/src/main.rs index 3eee2f5..0565078 100644 --- a/blacklight-node/src/main.rs +++ b/blacklight-node/src/main.rs @@ -1,470 +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_util::sync::CancellationToken; -use tracing::{debug, error, info, warn}; +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_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))] - { - 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"); - } - } - } -} - -// ============================================================================ -// 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_token: CancellationToken, - node_address: Address, -) -> Result<()> { - let htx_id = event.heartbeatKey; - // Debug: log the raw HTX bytes - let raw_bytes: &[u8] = &event.rawHTX; - tracing::debug!( - htx_id = ?htx_id, - raw_len = raw_bytes.len(), - raw_hex = %alloy::hex::encode(raw_bytes), - "Raw HTX bytes" - ); - // 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"); - verifier.verify_nillion_htx(&htx).await - } - Htx::Phala(htx) => { - info!(htx_id = ?htx_id, "Detected Phala HTX"); - 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" - ); - 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, 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_token.cancel(); - 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_token: CancellationToken, -) -> 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_token.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_token: &CancellationToken, -) -> 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_token.cancelled() => { - 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_token: CancellationToken, - verifier: &HtxVerifier, - verified_counter: Arc, -) -> Result<()> { - let client_for_callback = client.clone(); - let counter_for_callback = verified_counter.clone(); - let shutdown_for_callback = shutdown_token.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_token.cancelled() => { - 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()?); @@ -478,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 + // Setup shutdown handler let shutdown_token = CancellationToken::new(); let shutdown_token_clone = shutdown_token.clone(); tokio::spawn(async move { - setup_shutdown_handler(shutdown_token_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_token).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_token.clone(), - ) - .await - { - error!(error = %e, "Failed to query historical assignments"); - } - - // Start listening for events - match run_event_listener( - client_arc, - current_address, - shutdown_token.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_token.cancelled() => { - 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..ee9f649 --- /dev/null +++ b/blacklight-node/src/supervisor/mod.rs @@ -0,0 +1,222 @@ +use alloy::primitives::Address; +use anyhow::{Result, anyhow}; +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() => { + return Err(anyhow!("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..56f4424 --- /dev/null +++ b/blacklight-node/src/supervisor/status.rs @@ -0,0 +1,49 @@ +use alloy::primitives::utils::{format_ether, format_units}; +use anyhow::{Result, anyhow}; +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(); + return Err(anyhow!("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 100% rename from blacklight-node/src/version.rs rename to blacklight-node/src/supervisor/version.rs From 8ed84c7f2a4a31b91f486b25415b7e8394354825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 12:28:52 +0100 Subject: [PATCH 12/17] chore: substitute DynSolTypes with SolTypes and resturcture HTX parsing --- .../src/htx/abi/erc8004.rs | 63 +++ .../src/htx/abi/mod.rs | 36 ++ .../src/htx/json/mod.rs | 24 ++ .../src/htx/json/nillion.rs | 69 ++++ .../src/htx/json/phala.rs | 27 ++ .../src/{htx.rs => htx/mod.rs} | 384 ++++++++---------- crates/blacklight-contract-clients/src/lib.rs | 2 +- 7 files changed, 395 insertions(+), 210 deletions(-) create mode 100644 crates/blacklight-contract-clients/src/htx/abi/erc8004.rs create mode 100644 crates/blacklight-contract-clients/src/htx/abi/mod.rs create mode 100644 crates/blacklight-contract-clients/src/htx/json/mod.rs create mode 100644 crates/blacklight-contract-clients/src/htx/json/nillion.rs create mode 100644 crates/blacklight-contract-clients/src/htx/json/phala.rs rename crates/blacklight-contract-clients/src/{htx.rs => htx/mod.rs} (52%) 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..f1bedef --- /dev/null +++ b/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs @@ -0,0 +1,63 @@ +//! ERC-8004 HTX - ABI-Encoded +//! +//! On-chain validation standard for agent validations. + +use alloy::primitives::{Address, B256, U256}; +use alloy::sol_types::{sol_data, SolType}; + +/// 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.rs b/crates/blacklight-contract-clients/src/htx/mod.rs similarity index 52% rename from crates/blacklight-contract-clients/src/htx.rs rename to crates/blacklight-contract-clients/src/htx/mod.rs index 2ff6624..8461952 100644 --- a/crates/blacklight-contract-clients/src/htx.rs +++ b/crates/blacklight-contract-clients/src/htx/mod.rs @@ -1,179 +1,71 @@ -use alloy::dyn_abi::{DynSolType, DynSolValue}; -use alloy::primitives::{Address, B256, Bytes, U256}; +//! # 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::{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) - } -} +pub use abi::*; +pub use json::*; -// 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), -} - -/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes. -/// Format: `abi.encode(validatorAddress, agentId, requestURI, requestHash)` +/// 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 struct Erc8004Htx { - pub validator_address: Address, - pub agent_id: U256, - pub request_uri: String, - pub request_hash: B256, +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 Erc8004Htx { - /// Try to decode ABI-encoded ERC-8004 validation data. - pub fn try_decode(data: &[u8]) -> Result { - let tuple_type = DynSolType::Tuple(vec![ - DynSolType::Address, - DynSolType::Uint(256), - DynSolType::String, - DynSolType::FixedBytes(32), - ]); - - let decoded = tuple_type - .abi_decode_params(data) - .map_err(|e| Erc8004DecodeError(e.to_string()))?; - - let values = match decoded { - DynSolValue::Tuple(values) => values, - _ => return Err(Erc8004DecodeError("Expected tuple".to_string())), - }; - - if values.len() != 4 { - return Err(Erc8004DecodeError(format!( - "Expected 4 values, got {}", - values.len() - ))); - } - - let validator_address = match &values[0] { - DynSolValue::Address(addr) => *addr, - _ => return Err(Erc8004DecodeError("Expected address".to_string())), - }; - - let agent_id = match &values[1] { - DynSolValue::Uint(val, _) => *val, - _ => return Err(Erc8004DecodeError("Expected uint256".to_string())), - }; - - let request_uri = match &values[2] { - DynSolValue::String(s) => s.clone(), - _ => return Err(Erc8004DecodeError("Expected string".to_string())), - }; - - let request_hash = match &values[3] { - DynSolValue::FixedBytes(word, 32) => B256::from_slice(word.as_slice()), - _ => return Err(Erc8004DecodeError("Expected bytes32".to_string())), - }; - - Ok(Self { - validator_address, - agent_id, - request_uri, - request_hash, - }) +impl From for Htx { + fn from(htx: json::NillionHtx) -> Self { + Htx::Nillion(htx) } } -#[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 From for Htx { + fn from(htx: json::PhalaHtx) -> Self { + Htx::Phala(htx) } } -impl std::error::Error for Erc8004DecodeError {} - -/// Unified HTX type that can represent nilCC, Phala, and ERC-8004 HTXs. -#[derive(Debug, Clone)] -pub enum Htx { - Nillion(NillionHtx), - Phala(PhalaHtx), - Erc8004(Erc8004Htx), -} - -/// JSON-serializable HTX types (Nillion and Phala only, not ERC-8004). -/// Used for loading HTXs from JSON files. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "provider", rename_all = "camelCase")] -pub enum JsonHtx { - Nillion(NillionHtx), - Phala(PhalaHtx), +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 { @@ -183,71 +75,93 @@ impl From for 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, trying JSON first then ABI decoding. + /// 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(erc8004_htx) = Erc8004Htx::try_decode(data) { - return Ok(erc8004_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 ERC-8004")] + #[error("Unknown HTX format: not valid JSON or ABI-encoded")] UnknownFormat, } -impl From for Htx { - fn from(htx: NillionHtx) -> Self { - Htx::Nillion(htx) - } -} - -impl From for Htx { - fn from(htx: PhalaHtx) -> Self { - Htx::Phala(htx) - } -} - -impl From for Htx { - fn from(htx: Erc8004Htx) -> Self { - Htx::Erc8004(htx) - } -} +// ============================================================================ +// 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) => { - let tuple = ( - htx.validator_address, - htx.agent_id, - htx.request_uri.clone(), - htx.request_hash, - ); - Ok(Bytes::from(tuple.abi_encode())) - } + 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) => { @@ -264,12 +178,22 @@ fn canonicalize_json(value: &Value) -> Value { } } +// ============================================================================ +// Tests +// ============================================================================ + #[cfg(test)] mod tests { use super::*; + // ------------------------------------------------------------------------ + // JSON-Encoded HTX Tests + // ------------------------------------------------------------------------ + #[test] - fn test_htx_deterministic_serialization() { + fn test_nillion_deterministic_serialization() { + use json::*; + // Create an HTX let htx = NillionHtxV1 { workload_id: WorkloadId { @@ -326,7 +250,34 @@ mod tests { } #[test] - fn test_htx_phala_serialization() { + 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 { @@ -346,7 +297,7 @@ mod tests { } #[test] - fn test_deserialize_phala() { + fn test_phala_deserialization() { let phala_json = r#"{ "provider": "phala", "version": "v1", @@ -364,9 +315,32 @@ mod tests { assert_eq!(htx.app_compose, "test-compose"); } + // ------------------------------------------------------------------------ + // ABI-Encoded HTX Tests + // ------------------------------------------------------------------------ + #[test] - fn test_deserialize_nillion() { - let nilcc_json = r#"{ + 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": { @@ -385,24 +359,16 @@ mod tests { } }"#; - let htx: JsonHtx = serde_json::from_str(nilcc_json).unwrap(); - assert!(matches!(htx, JsonHtx::Nillion(_)), "not a nillion HTX"); + let htx = Htx::try_parse(json_data.as_bytes()).unwrap(); + assert!(matches!(htx, Htx::Nillion(_))); } #[test] - fn test_erc8004_decode() { - // Test data: abi.encode(0x5fc8d32690cc91d4c39d9d3abcbd16989f875707, 0, "https://api.nilai.nillion.network/", 0xa6719a2ea05fac172c1b20e16beea2a9739b715499a3a9ad488e6ce81602ffac) + fn test_htx_parse_abi() { 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, U256::ZERO); - assert_eq!(htx.request_uri, "https://api.nilai.nillion.network/"); + 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 4595134..4708189 100644 --- a/crates/blacklight-contract-clients/src/lib.rs +++ b/crates/blacklight-contract-clients/src/lib.rs @@ -135,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, }; From 6acfe4ef8c875573d542c042938998cbb43f689f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 12:36:33 +0100 Subject: [PATCH 13/17] chore: use more idiomatic bail!() --- blacklight-node/src/supervisor/mod.rs | 4 ++-- blacklight-node/src/supervisor/status.rs | 4 ++-- blacklight-node/src/supervisor/version.rs | 6 +++--- .../src/tx_submitter.rs | 21 +++++++++++-------- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/blacklight-node/src/supervisor/mod.rs b/blacklight-node/src/supervisor/mod.rs index ee9f649..a8167e6 100644 --- a/blacklight-node/src/supervisor/mod.rs +++ b/blacklight-node/src/supervisor/mod.rs @@ -1,5 +1,5 @@ use alloy::primitives::Address; -use anyhow::{Result, anyhow}; +use anyhow::{Result, bail}; use blacklight_contract_clients::{BlacklightClient, ContractConfig}; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -137,7 +137,7 @@ impl<'a> Supervisor<'a> { ); } _ = shutdown_token.cancelled() => { - return Err(anyhow!("Shutdown requested during initial connect")); + bail!("Shutdown requested during initial connect"); } } } diff --git a/blacklight-node/src/supervisor/status.rs b/blacklight-node/src/supervisor/status.rs index 56f4424..5400e80 100644 --- a/blacklight-node/src/supervisor/status.rs +++ b/blacklight-node/src/supervisor/status.rs @@ -1,5 +1,5 @@ use alloy::primitives::utils::{format_ether, format_units}; -use anyhow::{Result, anyhow}; +use anyhow::{Result, bail}; use blacklight_contract_clients::BlacklightClient; use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; @@ -37,7 +37,7 @@ pub async fn check_minimum_balance( "⚠️ ETH balance below minimum threshold. Initiating shutdown..." ); shutdown_token.cancel(); - return Err(anyhow!("Insufficient ETH balance")); + bail!("Insufficient ETH balance"); } } Err(e) => { diff --git a/blacklight-node/src/supervisor/version.rs b/blacklight-node/src/supervisor/version.rs index f82492c..4862a03 100644 --- a/blacklight-node/src/supervisor/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/crates/contract-clients-common/src/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs index 206e9ab..dbba675 100644 --- a/crates/contract-clients-common/src/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -3,7 +3,7 @@ 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) From ddbb9e6aa1cad8864481d0e932385a049777d74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 13:17:34 +0100 Subject: [PATCH 14/17] fix: incorrect rebase --- crates/contract-clients-common/src/errors.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/contract-clients-common/src/errors.rs b/crates/contract-clients-common/src/errors.rs index 6c4de35..653f363 100644 --- a/crates/contract-clients-common/src/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -535,10 +535,10 @@ mod tests { /// - Bytes 68+: UTF-8 string data (padded to 32 bytes) #[test] fn test_decode_error_string() { - // "NilAV: unknown HTX" encoded as Error(string) + // "blacklight: unknown HTX" encoded as Error(string) // Selector: 08c379a0 // Offset: 0000...0020 (32 bytes) - // Length: 0000...0012 (18 bytes = "NilAV: unknown HTX".len()) + // Length: 0000...0012 (18 bytes = "blacklight: unknown HTX".len()) // Data: 4e696c41563a20756e6b6e6f776e20485458 + padding let data = hex::decode( "08c379a0\ @@ -552,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), } @@ -605,7 +605,7 @@ mod tests { 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 From 212fe0136c7dad9f3624b732bf033b549f0b9912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 13:21:25 +0100 Subject: [PATCH 15/17] fix: merged event L2 metrics with ERC 8004 --- keeper/src/erc8004/events.rs | 3 ++- keeper/src/erc8004/responder.rs | 2 +- keeper/src/l2/events.rs | 1 + keeper/src/metrics.rs | 24 +++++++----------------- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/keeper/src/erc8004/events.rs b/keeper/src/erc8004/events.rs index 9f71866..93ae189 100644 --- a/keeper/src/erc8004/events.rs +++ b/keeper/src/erc8004/events.rs @@ -109,7 +109,7 @@ impl Erc8004EventListener

{ .into_stream() .filter_map(async move |e| match e { Ok((event, _)) => { - metrics::get().erc8004.inc_events_received(event_name); + metrics::get().l2.events.inc_events_received(event_name); Some(event) } Err(e) => { @@ -146,6 +146,7 @@ impl Erc8004EventListener

{ .pending_validations .insert(heartbeat_key, info); metrics::get() + .l2 .erc8004 .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); diff --git a/keeper/src/erc8004/responder.rs b/keeper/src/erc8004/responder.rs index ea16b57..03dbd56 100644 --- a/keeper/src/erc8004/responder.rs +++ b/keeper/src/erc8004/responder.rs @@ -62,7 +62,7 @@ impl ValidationResponder

{ if let Some(info) = state.erc8004.pending_validations.get_mut(&heartbeat_key) { info.response_submitted = true; } - metrics::get().erc8004.inc_responses_submitted(); + metrics::get().l2.erc8004.inc_responses_submitted(); info!( request_hash = ?request_hash, outcome, diff --git a/keeper/src/l2/events.rs b/keeper/src/l2/events.rs index 8eb6d82..3a29f8c 100644 --- a/keeper/src/l2/events.rs +++ b/keeper/src/l2/events.rs @@ -244,6 +244,7 @@ impl EventListener { .pending_validations .insert(event.heartbeatKey, info); metrics::get() + .l2 .erc8004 .set_requests_tracked(guard.erc8004.pending_validations.len() as u64); info!( diff --git a/keeper/src/metrics.rs b/keeper/src/metrics.rs index de8a217..b94b5dd 100644 --- a/keeper/src/metrics.rs +++ b/keeper/src/metrics.rs @@ -17,7 +17,6 @@ pub(crate) fn get() -> &'static Metrics { pub(crate) struct Metrics { pub(crate) l1: L1Metrics, pub(crate) l2: L2Metrics, - pub(crate) erc8004: Erc8004Metrics, // A private guard to prevent this type from being constructed outside of this module. _private: (), } @@ -26,11 +25,9 @@ impl Metrics { fn new(meter: &Meter) -> Self { let l1 = L1Metrics::new(meter); let l2 = L2Metrics::new(meter); - let erc8004 = Erc8004Metrics::new(meter); Self { l1, l2, - erc8004, _private: (), } } @@ -122,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 { @@ -130,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, } } } @@ -253,37 +253,27 @@ impl L2EthMetrics { } } -pub(crate) struct Erc8004Metrics { - events_received: Counter, +pub(crate) struct L2Erc8004Metrics { requests_tracked: Gauge, responses_submitted: Counter, } -impl Erc8004Metrics { +impl L2Erc8004Metrics { fn new(meter: &Meter) -> Self { - let events_received = meter - .u64_counter("blacklight.keeper.erc8004.events.received") - .with_description("Total ERC-8004 events received") - .build(); let requests_tracked = meter - .u64_gauge("blacklight.keeper.erc8004.requests_tracked") + .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.erc8004.responses_submitted") + .u64_counter("blacklight.keeper.l2.erc8004.responses_submitted") .with_description("Total ERC-8004 validation responses submitted") .build(); Self { - events_received, requests_tracked, responses_submitted, } } - pub(crate) fn inc_events_received(&self, name: &'static str) { - self.events_received.add(1, &[KeyValue::new("name", name)]); - } - pub(crate) fn set_requests_tracked(&self, count: u64) { self.requests_tracked.record(count, &[]); } From 565b9dac1dc6563a3a601616bba830c257009993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 14:20:15 +0100 Subject: [PATCH 16/17] fix: code organization and commenting --- .../src/htx/abi/erc8004.rs | 5 ++--- crates/contract-clients-common/src/errors.rs | 11 ++++++----- .../src/validation_registry.rs | 18 ------------------ keeper/src/erc8004/events.rs | 2 +- keeper/src/l2/supervisor.rs | 1 - 5 files changed, 9 insertions(+), 28 deletions(-) diff --git a/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs b/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs index f1bedef..a214ef2 100644 --- a/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs +++ b/crates/blacklight-contract-clients/src/htx/abi/erc8004.rs @@ -3,7 +3,7 @@ //! On-chain validation standard for agent validations. use alloy::primitives::{Address, B256, U256}; -use alloy::sol_types::{sol_data, SolType}; +use alloy::sol_types::{SolType, sol_data}; /// ERC-8004 Validation HTX data parsed from ABI-encoded bytes. /// @@ -38,8 +38,7 @@ impl Erc8004Htx { ); let (validator_address, agent_id, request_uri, request_hash) = - Erc8004Tuple::abi_decode_params(data) - .map_err(|e| Erc8004DecodeError(e.to_string()))?; + Erc8004Tuple::abi_decode_params(data).map_err(|e| Erc8004DecodeError(e.to_string()))?; Ok(Self { validator_address, diff --git a/crates/contract-clients-common/src/errors.rs b/crates/contract-clients-common/src/errors.rs index 653f363..fc25638 100644 --- a/crates/contract-clients-common/src/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -538,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(); @@ -601,7 +601,8 @@ mod tests { #[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 { diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs index 86eb653..e0d85b0 100644 --- a/crates/erc-8004-contract-clients/src/validation_registry.rs +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -76,24 +76,6 @@ impl ValidationRegistryClient

{ *self.contract.address() } - /// Rust stub for the Solidity `requestValidation` semantics (no snapshotId). - pub async fn request_validation( - &self, - validator_address: Address, - agent_id: U256, - request_uri: String, - request_hash: B256, - ) -> Result { - let call = self.contract.validationRequest( - validator_address, - agent_id, - request_uri, - request_hash, - 0, - ); - self.submitter.invoke("validationRequest", call).await - } - /// Full validation request with snapshot ID (delegates to `validationRequest`). pub async fn validation_request( &self, diff --git a/keeper/src/erc8004/events.rs b/keeper/src/erc8004/events.rs index 93ae189..f5ad1eb 100644 --- a/keeper/src/erc8004/events.rs +++ b/keeper/src/erc8004/events.rs @@ -186,9 +186,9 @@ pub fn compute_heartbeat_key( 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 { - 0 => 50, // inconclusive 1 => 100, // valid 2 => 0, // invalid + 3 => 50, // inconclusive other => { warn!( heartbeat_key = %heartbeat_key, diff --git a/keeper/src/l2/supervisor.rs b/keeper/src/l2/supervisor.rs index 32cd487..c9cfd73 100644 --- a/keeper/src/l2/supervisor.rs +++ b/keeper/src/l2/supervisor.rs @@ -150,7 +150,6 @@ impl L2Supervisor { self.process_rounds(block_timestamp).await; - // Process ERC-8004 validation responses if let Some(ref responder) = erc8004_responder { debug!("Tick: processing ERC-8004 validation responses"); if let Err(e) = responder.process_responses().await { From 07752a3602c0b106f54f558b8ff9aa27f0c5d86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Cabrero-Holgueras?= Date: Fri, 6 Feb 2026 15:32:00 +0100 Subject: [PATCH 17/17] fix: adjusted docker compose number of accounts --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5182681..73616b7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: - "8545:8545" networks: - blacklight-network - command: ["--accounts", "20"] # 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 @@ -130,7 +130,7 @@ 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