diff --git a/.gitignore b/.gitignore index 8f9995e..3d4c2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ coverage/ .claude/ -blacklight_node/ \ No newline at end of file +blacklight_node/ +CLAUDE.md diff --git a/Cargo.toml b/Cargo.toml index 6593041..2301d59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,8 +5,10 @@ members = [ "blacklight-node", "crates/blacklight-contract-clients", "crates/chain-args", + "crates/contract-clients-common", + "crates/erc-8004-contract-clients", "crates/state-file", "keeper", "monitor", - "nilcc-simulator" + "simulator" ] diff --git a/blacklight-node/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..2f09b0b 100644 --- a/crates/blacklight-contract-clients/Cargo.toml +++ b/crates/blacklight-contract-clients/Cargo.toml @@ -7,9 +7,11 @@ edition = "2024" anyhow = "1.0" alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } alloy-provider = { version = "1.1", features = ["ws"] } +contract-clients-common = { path = "../contract-clients-common" } futures-util = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_with = { version = "3.16", features = ["hex"] } +thiserror = "1.0" tokio = { version = "1.49", features = ["sync"] } tracing = "0.1" diff --git a/crates/blacklight-contract-clients/src/blacklight_client.rs b/crates/blacklight-contract-clients/src/blacklight_client.rs index 5f6c161..95dcfe5 100644 --- a/crates/blacklight-contract-clients/src/blacklight_client.rs +++ b/crates/blacklight-contract-clients/src/blacklight_client.rs @@ -3,20 +3,15 @@ use crate::{ StakingOperatorsClient, }; use alloy::{ - network::{Ethereum, EthereumWallet, NetworkWallet}, - primitives::{Address, B256, TxKind, U256}, - providers::{DynProvider, Provider, ProviderBuilder, WsConnect}, - rpc::types::TransactionRequest, - signers::local::PrivateKeySigner, + primitives::{Address, B256, U256}, + providers::DynProvider, }; -use std::sync::Arc; -use tokio::sync::Mutex; +use contract_clients_common::ProviderContext; /// High-level wrapper bundling all contract clients with a shared Alloy provider. #[derive(Clone)] pub struct BlacklightClient { - provider: DynProvider, - wallet: EthereumWallet, + ctx: ProviderContext, pub manager: HeartbeatManagerClient, pub token: NilTokenClient, pub staking: StakingOperatorsClient, @@ -25,26 +20,26 @@ pub struct BlacklightClient { impl BlacklightClient { pub async fn new(config: ContractConfig, private_key: String) -> anyhow::Result { - let rpc_url = config.rpc_url.clone(); - let ws_url = rpc_url - .replace("http://", "ws://") - .replace("https://", "wss://"); + let ctx = ProviderContext::with_ws_retries( + &config.rpc_url, + &private_key, + Some(config.max_ws_retries), + ) + .await?; - // Build WS transport with configurable retries - let ws = WsConnect::new(ws_url).with_max_retries(config.max_ws_retries); - let signer: PrivateKeySigner = private_key.parse::()?; - let wallet = EthereumWallet::from(signer); - - // Build a provider that can sign transactions, then erase the concrete type - let provider: DynProvider = ProviderBuilder::new() - .wallet(wallet.clone()) - .with_simple_nonce_management() - .with_gas_estimation() - .connect_ws(ws) - .await? - .erased(); + Self::from_context(ctx, config).await + } - let tx_lock = Arc::new(Mutex::new(())); + /// Create a client from an existing [`ProviderContext`]. + /// + /// Use this when you want to share the same provider, wallet, and nonce + /// tracker across multiple clients (e.g. `BlacklightClient` and `Erc8004Client`). + pub async fn from_context( + ctx: ProviderContext, + config: ContractConfig, + ) -> anyhow::Result { + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); // Instantiate contract clients using the shared provider let manager = @@ -54,11 +49,10 @@ impl BlacklightClient { let protocol_config_address = staking.protocol_config().await?; let protocol_config = - ProtocolConfigClient::new(provider.clone(), protocol_config_address, tx_lock.clone()); + ProtocolConfigClient::new(provider.clone(), protocol_config_address, tx_lock); Ok(Self { - provider, - wallet, + ctx, manager, token, staking, @@ -68,31 +62,21 @@ impl BlacklightClient { /// Get the signer address pub fn signer_address(&self) -> Address { - >::default_signer_address(&self.wallet) + self.ctx.signer_address() } /// Get the balance of the wallet pub async fn get_balance(&self) -> anyhow::Result { - let address = self.signer_address(); - Ok(self.provider.get_balance(address).await?) + self.ctx.get_balance().await } /// Get the balance of a specific address pub async fn get_balance_of(&self, address: Address) -> anyhow::Result { - Ok(self.provider.get_balance(address).await?) + self.ctx.get_balance_of(address).await } /// Send ETH to an address pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { - let tx = TransactionRequest { - to: Some(TxKind::Call(to)), - value: Some(amount), - max_priority_fee_per_gas: Some(0), - ..Default::default() - }; - - let tx_hash = self.provider.send_transaction(tx).await?.watch().await?; - - Ok(tx_hash) + self.ctx.send_eth(to, amount).await } } diff --git a/crates/blacklight-contract-clients/src/common/mod.rs b/crates/blacklight-contract-clients/src/common/mod.rs deleted file mode 100644 index 8d581c0..0000000 --- a/crates/blacklight-contract-clients/src/common/mod.rs +++ /dev/null @@ -1,22 +0,0 @@ -use crate::common::errors::decode_any_error; -use alloy::{ - contract::{CallBuilder, CallDecoder}, - providers::Provider, -}; -use anyhow::anyhow; - -pub mod errors; -pub mod event_helper; -pub mod tx_submitter; - -pub async fn overestimate_gas( - call: &CallBuilder, -) -> anyhow::Result { - // Estimate gas and add a 50% buffer - let estimated_gas = call.estimate_gas().await.map_err(|e| { - let decoded = decode_any_error(&e); - anyhow!("failed to estimate gas: {decoded}") - })?; - let gas_with_buffer = estimated_gas.saturating_add(estimated_gas / 2); - Ok(gas_with_buffer) -} diff --git a/crates/blacklight-contract-clients/src/heartbeat_manager.rs b/crates/blacklight-contract-clients/src/heartbeat_manager.rs index 7671b21..df9744e 100644 --- a/crates/blacklight-contract-clients/src/heartbeat_manager.rs +++ b/crates/blacklight-contract-clients/src/heartbeat_manager.rs @@ -1,9 +1,5 @@ -use crate::common::event_helper::{BlockRange, listen_events, listen_events_filtered}; use crate::htx::Htx; -use crate::{ - common::tx_submitter::TransactionSubmitter, - heartbeat_manager::HeartbeatManager::HeartbeatManagerInstance, -}; +use HeartbeatManager::HeartbeatManagerInstance; use alloy::{ primitives::{Address, B256, U256, keccak256}, providers::Provider, @@ -11,6 +7,8 @@ use alloy::{ sol_types::SolValue, }; use anyhow::{Context, Result, anyhow, bail}; +use contract_clients_common::event_helper::{BlockRange, listen_events, listen_events_filtered}; +use contract_clients_common::tx_submitter::TransactionSubmitter; use std::sync::Arc; use tokio::sync::Mutex; diff --git a/crates/blacklight-contract-clients/src/htx.rs b/crates/blacklight-contract-clients/src/htx.rs index f5bab46..2ff6624 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,30 +81,173 @@ pub enum PhalaHtx { V1(PhalaHtxV1), } -// Unified HTX type that can represent both nilCC and Phala HTXs +/// 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, + pub agent_id: U256, + pub request_uri: String, + pub request_hash: B256, +} + +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, + }) + } +} + +#[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). +/// Used 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 { + 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()); + } + + 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) => 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())) + } + } } } +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) => { @@ -212,8 +357,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 +385,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/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/contract-clients-common/Cargo.toml b/crates/contract-clients-common/Cargo.toml new file mode 100644 index 0000000..42e75f7 --- /dev/null +++ b/crates/contract-clients-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "contract-clients-common" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } +alloy-provider = { version = "1.1", features = ["ws"] } +futures-util = "0.3" +tokio = { version = "1.49", features = ["sync"] } +tracing = "0.1" diff --git a/crates/blacklight-contract-clients/src/common/errors.rs b/crates/contract-clients-common/src/errors.rs similarity index 76% rename from crates/blacklight-contract-clients/src/common/errors.rs rename to crates/contract-clients-common/src/errors.rs index 64488d5..6c4de35 100644 --- a/crates/blacklight-contract-clients/src/common/errors.rs +++ b/crates/contract-clients-common/src/errors.rs @@ -14,28 +14,16 @@ //! - Selector: `0x4e487b71` //! - Includes overflow, division by zero, array bounds, etc. //! -//! 3. **Custom Contract Errors** - Gas-efficient custom errors from contract ABIs -//! - Currently supports: `StakingOperatorsErrors`, we need to add more for the custom contracts such as HeartbeatManagerErrors +//! 3. **Custom Contract Errors** - Extensible via [`decode_revert_with_custom`] //! - Each error has a unique 4-byte selector derived from its signature +//! - Consumers can provide their own custom error decoders //! //! ## Usage Flow //! //! ```text //! Transaction reverts → RPC returns error with hex data → //! decode_any_error() → try_extract_from_string() → decode_revert() → -//! → "blacklight: HTX already exists" (human-readable!) -//! ``` -//! -//! ## Example -//! -//! Instead of seeing: -//! ```text -//! error: execution reverted: 0x08c379a0000000... -//! ``` -//! -//! You now see: -//! ```text -//! error: blacklight: HTX already exists +//! → "contract: request already exists" (human-readable!) //! ``` //! //! ## Main Entry Points @@ -43,6 +31,7 @@ //! - [`decode_any_error`] - Generic entry point for any error type //! - [`extract_revert_from_contract_error`] - For Alloy's `ContractError` type //! - [`decode_revert`] - For raw `Bytes` revert data +//! - [`decode_revert_with_custom`] - For raw `Bytes` with custom error decoder use alloy::{ contract::Error as ContractError, hex, primitives::Bytes, sol, sol_types::SolInterface, @@ -72,28 +61,6 @@ sol! { } } -// ============================================================================ -// Contract-specific Errors - Extracted from ABIs -// ============================================================================ -// -// Custom Solidity errors are more gas-efficient than `require()` with strings. -// The `sol!` macro in the contract binding modules automatically generates -// Rust types for all custom errors defined in the contract ABI. -// -// To add support for a new contract's errors: -// 1. Ensure the contract module uses `sol!` to generate bindings -// 2. Re-export the errors enum here: `pub use super::module::Contract::ContractErrors;` -// 3. Add a case in `decode_revert()` to try decoding with the new error type -// 4. Add a `format_X_error()` function to provide human-readable messages - -/// Re-export StakingOperators custom errors from the contract bindings. -/// These are automatically generated by the `sol!` macro from the contract ABI. -pub use crate::staking_operators::StakingOperators::StakingOperatorsErrors; - -// Note: HeartbeatManager currently uses require() with string messages, not custom errors. -// When custom errors are added to the contract, they will be automatically available -// via HeartbeatManager::HeartbeatManagerErrors and should be added here. - // ============================================================================ // DecodedRevert Enum - The Result of Decoding // ============================================================================ @@ -106,7 +73,7 @@ pub use crate::staking_operators::StakingOperators::StakingOperatorsErrors; /// |---------|----------------| /// | `ErrorString` | `require()` failed with a message | /// | `Panic` | `assert()` failed or arithmetic error | -/// | `StakingError` | Custom error from StakingOperators contract | +/// | `CustomError` | Custom error decoded by consumer-provided decoder | /// | `RawRevert` | We got hex data but couldn't decode it | /// | `NoRevertData` | No revert data at all (unusual) | #[derive(Debug, Clone)] @@ -120,9 +87,9 @@ pub enum DecodedRevert { /// See [`panic_reason`] for code meanings. Panic(u64), - /// Custom error from the StakingOperators contract. - /// These are gas-efficient errors defined in the contract's ABI. - StakingError(String), + /// Custom error decoded by a consumer-provided decoder. + /// The string contains a human-readable description of the error. + CustomError(String), /// Raw revert data that couldn't be decoded by any known error type. /// Contains the hex bytes so the user can manually debug. @@ -138,7 +105,7 @@ impl std::fmt::Display for DecodedRevert { match self { DecodedRevert::ErrorString(msg) => write!(f, "{}", msg), DecodedRevert::Panic(code) => write!(f, "Panic({}): {}", code, panic_reason(*code)), - DecodedRevert::StakingError(msg) => write!(f, "{}", msg), + DecodedRevert::CustomError(msg) => write!(f, "{}", msg), DecodedRevert::RawRevert(data) => write!(f, "Raw revert data: {}", data), DecodedRevert::NoRevertData(details) => write!(f, "No revert data ({})", details), } @@ -168,7 +135,7 @@ impl std::fmt::Display for DecodedRevert { /// | 0x32 | Array index out of bounds | /// | 0x41 | Memory allocation overflow | /// | 0x51 | Zero-initialized function pointer call | -fn panic_reason(code: u64) -> &'static str { +pub fn panic_reason(code: u64) -> &'static str { match code { 0x00 => "generic compiler panic", 0x01 => "assertion failed", @@ -193,12 +160,34 @@ fn panic_reason(code: u64) -> &'static str { /// This function attempts to decode the raw bytes in the following order: /// 1. **Standard `Error(string)`** - Most common from `require()` /// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow -/// 3. **Custom `StakingOperatorsErrors`** - Contract-specific errors +/// 3. **Fallback** - Return the raw hex so user can debug +/// +/// For custom error decoding, use [`decode_revert_with_custom`] instead. +/// +/// # Arguments +/// +/// * `data` - Raw ABI-encoded revert data from the EVM +/// +/// # Returns +/// +/// A [`DecodedRevert`] variant representing the decoded error. +pub fn decode_revert(data: &Bytes) -> DecodedRevert { + decode_revert_with_custom(data, |_| None) +} + +/// Decode raw revert data bytes with a custom error decoder. +/// +/// This function attempts to decode the raw bytes in the following order: +/// 1. **Standard `Error(string)`** - Most common from `require()` +/// 2. **Standard `Panic(uint256)`** - From `assert()` or overflow +/// 3. **Custom errors** - Via the provided `custom_decoder` /// 4. **Fallback** - Return the raw hex so user can debug /// /// # Arguments /// /// * `data` - Raw ABI-encoded revert data from the EVM +/// * `custom_decoder` - A function that attempts to decode custom contract errors. +/// Returns `Some(DecodedRevert)` if the error was recognized, `None` otherwise. /// /// # Returns /// @@ -207,11 +196,20 @@ fn panic_reason(code: u64) -> &'static str { /// # Example /// /// ```ignore -/// let revert_data = Bytes::from(hex::decode("08c379a0...").unwrap()); -/// let decoded = decode_revert(&revert_data); -/// println!("Error: {}", decoded); // "blacklight: HTX already exists" +/// use contract_clients_common::errors::{decode_revert_with_custom, DecodedRevert}; +/// +/// let decoded = decode_revert_with_custom(&data, |bytes| { +/// if let Ok(err) = MyContractErrors::abi_decode(bytes) { +/// Some(DecodedRevert::CustomError(format!("{:?}", err))) +/// } else { +/// None +/// } +/// }); /// ``` -pub fn decode_revert(data: &Bytes) -> DecodedRevert { +pub fn decode_revert_with_custom(data: &Bytes, custom_decoder: F) -> DecodedRevert +where + F: FnOnce(&Bytes) -> Option, +{ // Empty revert data is unusual - contracts normally include some data if data.is_empty() { return DecodedRevert::NoRevertData("empty revert data".to_string()); @@ -231,11 +229,9 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { } } - // Step 2: Try to decode as StakingOperators custom errors - // Each custom error has a unique 4-byte selector derived from its signature - if let Ok(err) = StakingOperatorsErrors::abi_decode(data) { - let msg = format_staking_error(&err); - return DecodedRevert::StakingError(msg); + // Step 2: Try custom error decoder + if let Some(decoded) = custom_decoder(data) { + return decoded; } // Step 3: Unknown error - return raw bytes so user can debug @@ -243,39 +239,6 @@ pub fn decode_revert(data: &Bytes) -> DecodedRevert { DecodedRevert::RawRevert(data.clone()) } -// ============================================================================ -// Human-Readable Error Formatting -// ============================================================================ - -/// Format a StakingOperators custom error into a human-readable message. -/// -/// This function provides user-friendly descriptions for each custom error -/// defined in the StakingOperators contract. -/// -/// # Arguments -/// -/// * `err` - The decoded StakingOperators error variant -/// -/// # Returns -/// -/// A human-readable error message string. -fn format_staking_error(err: &StakingOperatorsErrors) -> String { - match err { - StakingOperatorsErrors::DifferentStaker(_) => "Different staker".to_string(), - StakingOperatorsErrors::InsufficientStake(_) => "Insufficient stake".to_string(), - StakingOperatorsErrors::NoStake(_) => "No stake".to_string(), - StakingOperatorsErrors::NoUnbonding(_) => "No unbonding request".to_string(), - StakingOperatorsErrors::NotActive(_) => "Operator not active".to_string(), - StakingOperatorsErrors::NotReady(_) => "Unbonding period not ready".to_string(), - StakingOperatorsErrors::NotStaker(_) => "Not a staker".to_string(), - StakingOperatorsErrors::OperatorJailed(_) => "Operator is jailed".to_string(), - StakingOperatorsErrors::PendingUnbonding(_) => "Pending unbonding exists".to_string(), - StakingOperatorsErrors::UnbondingExists(_) => "Unbonding already exists".to_string(), - StakingOperatorsErrors::ZeroAddress(_) => "Zero address not allowed".to_string(), - StakingOperatorsErrors::ZeroAmount(_) => "Zero amount not allowed".to_string(), - } -} - // ============================================================================ // Alloy ContractError Extraction // ============================================================================ @@ -303,10 +266,24 @@ fn format_staking_error(err: &StakingOperatorsErrors) -> String { /// /// A [`DecodedRevert`] with the decoded error or context about why decoding failed. pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRevert { + extract_revert_from_contract_error_with_custom(error, |_| None) +} + +/// Extract and decode revert data from an Alloy [`ContractError`] with custom decoder. +/// +/// Same as [`extract_revert_from_contract_error`] but allows providing a custom +/// error decoder for contract-specific errors. +pub fn extract_revert_from_contract_error_with_custom( + error: &ContractError, + custom_decoder: F, +) -> DecodedRevert +where + F: Fn(&Bytes) -> Option, +{ match error { // TransportError is the most common - it wraps RPC error responses ContractError::TransportError(transport_err) => { - extract_revert_from_transport_error(transport_err) + extract_revert_from_transport_error_with_custom(transport_err, custom_decoder) } // ABI errors happen when encoding/decoding fails (rare) ContractError::AbiError(abi_err) => { @@ -316,7 +293,8 @@ pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRever // This is a fallback - ideally we'd handle all variants explicitly _ => { let debug_str = format!("{:?}", error); - if let Some(decoded) = try_extract_from_string(&debug_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) + { decoded } else { DecodedRevert::NoRevertData(format!("Unknown error type: {}", error)) @@ -338,7 +316,13 @@ pub fn extract_revert_from_contract_error(error: &ContractError) -> DecodedRever /// # Returns /// /// A [`DecodedRevert`] with the decoded error data. -fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert { +fn extract_revert_from_transport_error_with_custom( + error: &TransportError, + custom_decoder: F, +) -> DecodedRevert +where + F: Fn(&Bytes) -> Option, +{ match error { TransportError::ErrorResp(err_resp) => { // The error response may contain revert data in the `data` field @@ -352,7 +336,7 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert if let Some(hex_data) = data_str.strip_prefix("0x") && let Ok(bytes) = hex::decode(hex_data) { - return decode_revert(&Bytes::from(bytes)); + return decode_revert_with_custom(&Bytes::from(bytes), |b| custom_decoder(b)); } // If not hex, include the raw data for debugging return DecodedRevert::NoRevertData(format!("Error data: {}", data_str)); @@ -363,7 +347,7 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert // For other transport errors (timeout, connection, etc.), try string extraction _ => { let err_str = error.to_string(); - if let Some(decoded) = try_extract_from_string(&err_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&err_str, &custom_decoder) { decoded } else { DecodedRevert::NoRevertData(format!("Transport error: {}", err_str)) @@ -396,7 +380,18 @@ fn extract_revert_from_transport_error(error: &TransportError) -> DecodedRevert /// # Returns /// /// `Some(DecodedRevert)` if hex data was found and decoded, `None` otherwise. -fn try_extract_from_string(error_str: &str) -> Option { +pub fn try_extract_from_string(error_str: &str) -> Option { + try_extract_from_string_with_custom(error_str, &|_| None) +} + +/// Try to extract revert data from an error string with a custom decoder. +fn try_extract_from_string_with_custom( + error_str: &str, + custom_decoder: &F, +) -> Option +where + F: Fn(&Bytes) -> Option, +{ // Patterns that indicate hex revert data follows const PATTERNS: &[&str] = &[ "execution reverted: 0x", @@ -438,14 +433,16 @@ fn try_extract_from_string(error_str: &str) -> Option { if hex_str.len() >= 10 { let without_prefix = hex_str.strip_prefix("0x").unwrap_or(hex_str); if let Ok(bytes) = hex::decode(without_prefix) { - return Some(decode_revert(&Bytes::from(bytes))); + return Some(decode_revert_with_custom(&Bytes::from(bytes), |b| { + custom_decoder(b) + })); } } } } // Special case: plain text error message after "execution reverted:" - // Some RPC providers return: `execution reverted: blacklight: HTX already exists` + // Some RPC providers return: `execution reverted: contract: request already exists` if error_str.contains("execution reverted") && let Some(idx) = error_str.find("execution reverted:") { @@ -490,16 +487,27 @@ fn try_extract_from_string(error_str: &str) -> Option { /// log::error!("Transaction failed: {}", decoded); /// ``` pub fn decode_any_error(error: &E) -> DecodedRevert { + decode_any_error_with_custom(error, |_| None) +} + +/// Decode ANY error with a custom error decoder. +/// +/// Same as [`decode_any_error`] but allows providing a custom error decoder. +pub fn decode_any_error_with_custom(error: &E, custom_decoder: F) -> DecodedRevert +where + E: std::fmt::Display + std::fmt::Debug, + F: Fn(&Bytes) -> Option, +{ let error_str = error.to_string(); let debug_str = format!("{:?}", error); // First try the Display representation - if let Some(decoded) = try_extract_from_string(&error_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&error_str, &custom_decoder) { return decoded; } // Then try the Debug representation (often has more details like struct fields) - if let Some(decoded) = try_extract_from_string(&debug_str) { + if let Some(decoded) = try_extract_from_string_with_custom(&debug_str, &custom_decoder) { return decoded; } @@ -527,10 +535,10 @@ mod tests { /// - Bytes 68+: UTF-8 string data (padded to 32 bytes) #[test] fn test_decode_error_string() { - // "blacklight: unknown HTX" encoded as Error(string) + // "NilAV: unknown HTX" encoded as Error(string) // Selector: 08c379a0 // Offset: 0000...0020 (32 bytes) - // Length: 0000...0012 (18 bytes = "blacklight: unknown HTX".len()) + // Length: 0000...0012 (18 bytes = "NilAV: unknown HTX".len()) // Data: 4e696c41563a20756e6b6e6f776e20485458 + padding let data = hex::decode( "08c379a0\ @@ -576,25 +584,6 @@ mod tests { } } - /// Test decoding a custom StakingOperators error. - /// - /// Custom errors with no parameters only need the 4-byte selector. - /// Selector for InsufficientStake(): cast sig "InsufficientStake()" = 0xf1bc94d2 - #[test] - fn test_decode_staking_error() { - // InsufficientStake() error - selector only, no params - let data = hex::decode("f1bc94d2").unwrap(); - - let decoded = decode_revert(&Bytes::from(data)); - - match decoded { - DecodedRevert::StakingError(msg) => { - assert_eq!(msg, "Insufficient stake"); - } - _ => panic!("Expected StakingError, got {:?}", decoded), - } - } - /// Test the Display implementation for all DecodedRevert variants. #[test] fn test_display() { @@ -604,8 +593,8 @@ mod tests { let panic = DecodedRevert::Panic(1); assert_eq!(format!("{}", panic), "Panic(1): assertion failed"); - let staking = DecodedRevert::StakingError("No stake".to_string()); - assert_eq!(format!("{}", staking), "No stake"); + let custom = DecodedRevert::CustomError("Custom error".to_string()); + assert_eq!(format!("{}", custom), "Custom error"); } /// Test extracting revert data from various error string formats. @@ -632,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..72f1fda --- /dev/null +++ b/crates/contract-clients-common/src/lib.rs @@ -0,0 +1,57 @@ +//! # Contract Clients Common +//! +//! Shared utilities for Ethereum contract clients using Alloy. +//! +//! This crate provides: +//! - **Error decoding**: Human-readable Solidity revert errors +//! - **Event helpers**: Utilities for event listening and querying +//! - **Transaction submission**: Reliable transaction submission with gas estimation +//! +//! ## Usage +//! +//! ```ignore +//! use contract_clients_common::{ +//! errors::{decode_any_error, DecodedRevert}, +//! event_helper::BlockRange, +//! tx_submitter::TransactionSubmitter, +//! }; +//! ``` + +use alloy::{ + contract::{CallBuilder, CallDecoder}, + providers::Provider, +}; +use anyhow::anyhow; + +use crate::errors::decode_any_error; + +pub mod errors; +pub mod event_helper; +pub mod provider_context; +pub mod tx_submitter; + +pub use provider_context::ProviderContext; + +/// Estimate gas for a contract call with a 50% buffer. +/// +/// This is useful for ensuring transactions have enough gas headroom, +/// especially for complex operations that may use more gas than estimated. +/// +/// # Arguments +/// +/// * `call` - The contract call to estimate gas for +/// +/// # Returns +/// +/// The estimated gas with a 50% buffer added. +pub async fn overestimate_gas( + call: &CallBuilder, +) -> anyhow::Result { + // Estimate gas and add a 50% buffer + let estimated_gas = call.estimate_gas().await.map_err(|e| { + let decoded = decode_any_error(&e); + anyhow!("failed to estimate gas: {decoded}") + })?; + let gas_with_buffer = estimated_gas.saturating_add(estimated_gas / 2); + Ok(gas_with_buffer) +} diff --git a/crates/contract-clients-common/src/provider_context.rs b/crates/contract-clients-common/src/provider_context.rs new file mode 100644 index 0000000..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/blacklight-contract-clients/src/common/tx_submitter.rs b/crates/contract-clients-common/src/tx_submitter.rs similarity index 99% rename from crates/blacklight-contract-clients/src/common/tx_submitter.rs rename to crates/contract-clients-common/src/tx_submitter.rs index 11a7ecf..206e9ab 100644 --- a/crates/blacklight-contract-clients/src/common/tx_submitter.rs +++ b/crates/contract-clients-common/src/tx_submitter.rs @@ -1,4 +1,4 @@ -use crate::common::overestimate_gas; +use crate::overestimate_gas; use alloy::{ consensus::Transaction, contract::CallBuilder, primitives::B256, providers::Provider, rpc::types::TransactionReceipt, sol_types::SolInterface, diff --git a/crates/erc-8004-contract-clients/Cargo.toml b/crates/erc-8004-contract-clients/Cargo.toml new file mode 100644 index 0000000..ff5b58b --- /dev/null +++ b/crates/erc-8004-contract-clients/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "erc-8004-contract-clients" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +alloy = { version = "1.1", features = ["contract", "providers", "pubsub"] } +alloy-provider = { version = "1.1", features = ["ws"] } +contract-clients-common = { path = "../contract-clients-common" } +futures-util = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_with = { version = "3.16", features = ["hex"] } +tokio = { version = "1.49", features = ["sync"] } +tracing = "0.1" diff --git a/crates/erc-8004-contract-clients/src/erc_8004_client.rs b/crates/erc-8004-contract-clients/src/erc_8004_client.rs new file mode 100644 index 0000000..c588fe2 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/erc_8004_client.rs @@ -0,0 +1,76 @@ +use crate::{ContractConfig, IdentityRegistryClient, ValidationRegistryClient}; +use alloy::{ + primitives::{Address, B256, U256}, + providers::DynProvider, +}; +use contract_clients_common::ProviderContext; + +/// High-level wrapper bundling ERC-8004 contract clients with a shared Alloy provider. +#[derive(Clone)] +pub struct Erc8004Client { + ctx: ProviderContext, + pub identity_registry: IdentityRegistryClient, + pub validation_registry: ValidationRegistryClient, +} + +impl Erc8004Client { + pub async fn new(config: ContractConfig, private_key: String) -> anyhow::Result { + let ctx = ProviderContext::new(&config.rpc_url, &private_key).await?; + Self::from_context(ctx, config).await + } + + /// Create a client from an existing [`ProviderContext`]. + /// + /// Use this when you want to share the same provider, wallet, and nonce + /// tracker across multiple clients (e.g. `BlacklightClient` and `Erc8004Client`). + pub async fn from_context( + ctx: ProviderContext, + config: ContractConfig, + ) -> anyhow::Result { + let provider = ctx.provider().clone(); + let tx_lock = ctx.tx_lock(); + + // Instantiate contract clients using the shared provider + let identity_registry = IdentityRegistryClient::new( + provider.clone(), + config.identity_registry_contract_address, + tx_lock.clone(), + ); + let validation_registry = ValidationRegistryClient::new( + provider.clone(), + config.validation_registry_contract_address, + tx_lock, + ); + + Ok(Self { + ctx, + identity_registry, + validation_registry, + }) + } + + /// Get the signer address + pub fn signer_address(&self) -> Address { + self.ctx.signer_address() + } + + /// Get the balance of the wallet + pub async fn get_balance(&self) -> anyhow::Result { + self.ctx.get_balance().await + } + + /// Get the balance of a specific address + pub async fn get_balance_of(&self, address: Address) -> anyhow::Result { + self.ctx.get_balance_of(address).await + } + + /// Send ETH to an address + pub async fn send_eth(&self, to: Address, amount: U256) -> anyhow::Result { + self.ctx.send_eth(to, amount).await + } + + /// Get the current block number + pub async fn get_block_number(&self) -> anyhow::Result { + self.ctx.get_block_number().await + } +} diff --git a/crates/erc-8004-contract-clients/src/identity_registry.rs b/crates/erc-8004-contract-clients/src/identity_registry.rs new file mode 100644 index 0000000..68b0377 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/identity_registry.rs @@ -0,0 +1,122 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol! { + #[derive(Debug)] + struct MetadataEntry { + string metadataKey; + bytes metadataValue; + } + + #[sol(rpc)] + #[derive(Debug)] + contract IdentityRegistryUpgradeable { + function register() external returns (uint256); + function register(string calldata agentURI) external returns (uint256); + function register(string calldata agentURI, MetadataEntry[] calldata metadata) external returns (uint256); + function ownerOf(uint256 agentId) external view returns (address); + function tokenURI(uint256 agentId) external view returns (string memory); + function getAgentWallet(uint256 agentId) external view returns (address); + + // ERC-721 Transfer event emitted on registration (mint) + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + } +} + +use IdentityRegistryUpgradeable::{IdentityRegistryUpgradeableInstance, Transfer}; + +pub type IdentityMetadataEntry = MetadataEntry; + +/// Client for interacting with the IdentityRegistryUpgradeable contract. +#[derive(Clone)] +pub struct IdentityRegistryClient { + provider: P, + contract: IdentityRegistryUpgradeableInstance

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

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

() + .expect("Invalid token address"), + validation_registry_contract_address: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512" + .parse::
() + .expect("Invalid validation registry address"), + rpc_url: "http://127.0.0.1:8545".to_string(), + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_config_creation() { + let identity_registry_address = "0x89c1312Cedb0B0F67e4913D2076bd4a860652B69" + .parse::
() + .unwrap(); + let validation_registry_address = "0xDc64a140Aa3E981100a9becA4E685f962f0cF6C9" + .parse::
() + .unwrap(); + + let config = ContractConfig::new( + "http://localhost:8545".to_string(), + identity_registry_address, + validation_registry_address, + ); + + assert_eq!( + config.identity_registry_contract_address, + identity_registry_address + ); + assert_eq!( + config.validation_registry_contract_address, + validation_registry_address + ); + assert_eq!(config.rpc_url, "http://localhost:8545"); + } + + #[test] + fn test_contract_address_parsing() { + let addr_str = "0x89c1312Cedb0B0F67e4913D2076bd4a860652B69"; + let addr = addr_str.parse::
(); + assert!(addr.is_ok(), "Contract address should parse correctly"); + } +} diff --git a/crates/erc-8004-contract-clients/src/validation_registry.rs b/crates/erc-8004-contract-clients/src/validation_registry.rs new file mode 100644 index 0000000..4a34787 --- /dev/null +++ b/crates/erc-8004-contract-clients/src/validation_registry.rs @@ -0,0 +1,86 @@ +use alloy::{ + primitives::{Address, B256, U256}, + providers::Provider, + sol, +}; +use anyhow::Result; +use contract_clients_common::tx_submitter::TransactionSubmitter; +use std::sync::Arc; +use tokio::sync::Mutex; + +sol! { + #[sol(rpc)] + #[derive(Debug)] + contract ValidationRegistryUpgradeable { + function validationRequest( + address validatorAddress, + uint256 agentId, + string calldata requestURI, + bytes32 requestHash, + uint64 snapshotId + ) external; + } +} + +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..43db1b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,13 @@ services: # Anvil - Local Ethereum testnet anvil: - image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-64cd680 + image: nilanvil:latest container_name: blacklight-anvil ports: - "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 @@ -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..2f23cd1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage Dockerfile for blacklight project with cross-compilation support -# Builds all binaries: blacklight_node, nilcc_simulator, monitor, keeper +# Builds all binaries: blacklight_node, simulator (nilcc/erc8004), monitor, keeper FROM --platform=$BUILDPLATFORM rust:1.91-slim-trixie AS builder @@ -57,7 +57,7 @@ RUN mkdir -p ~/.cargo && \ # Copy project files COPY Cargo.toml ./ COPY crates ./crates -COPY nilcc-simulator ./nilcc-simulator +COPY simulator ./simulator COPY keeper ./keeper COPY blacklight-node ./blacklight-node COPY monitor ./monitor @@ -84,7 +84,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo build --release --target $RUST_TARGET && \ mkdir -p /out/bin && \ cp target/$RUST_TARGET/release/blacklight-node /out/bin/ && \ - cp target/$RUST_TARGET/release/nilcc-simulator /out/bin/ && \ + cp target/$RUST_TARGET/release/simulator /out/bin/ && \ cp target/$RUST_TARGET/release/monitor /out/bin/ && \ cp target/$RUST_TARGET/release/keeper /out/bin/ @@ -112,12 +112,17 @@ COPY --from=foundry /usr/local/bin/cast /usr/local/bin/cast COPY --chmod=755 docker/entrypoints/derive_private_key.sh /usr/local/bin/derive_private_key.sh ENTRYPOINT ["/usr/local/bin/derive_private_key.sh", "/usr/local/bin/blacklight-node"] -# Runtime stage for nilcc_simulator +# Runtime stage for nilcc_simulator (HTX submission) FROM base_release AS nilcc_simulator -COPY --from=builder /out/bin/nilcc-simulator /usr/local/bin/nilcc-simulator +COPY --from=builder /out/bin/simulator /usr/local/bin/simulator COPY --from=builder /app/data /app/data ENV HTXS_PATH=/app/data/htxs.json -ENTRYPOINT ["/usr/local/bin/nilcc-simulator"] +ENTRYPOINT ["/usr/local/bin/simulator", "nilcc"] + +# Runtime stage for erc_8004_simulator (ERC-8004 validation requests) +FROM base_release AS erc_8004_simulator +COPY --from=builder /out/bin/simulator /usr/local/bin/simulator +ENTRYPOINT ["/usr/local/bin/simulator", "erc8004"] # Runtime stage for monitor FROM base_release AS monitor diff --git a/keeper/Cargo.toml b/keeper/Cargo.toml index b68d42e..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}; diff --git a/nilcc-simulator/src/args.rs b/nilcc-simulator/src/args.rs deleted file mode 100644 index f321941..0000000 --- a/nilcc-simulator/src/args.rs +++ /dev/null @@ -1,88 +0,0 @@ -use alloy::primitives::Address; -use anyhow::Result; -use clap::Parser; - -use chain_args::{ChainArgs, ChainConfig}; -use state_file::StateFile; -use tracing::info; - -const STATE_FILE_SIMULATOR: &str = "nilcc_simulator.env"; - -/// Default path to HTXs JSON file -const DEFAULT_HTXS_PATH: &str = "data/htxs.json"; - -/// Default slot interval in milliseconds - how often simulator submits HTXs -#[cfg(debug_assertions)] -const DEFAULT_SLOT_MS: u64 = 3000; // 3 seconds for debug (faster testing) - -#[cfg(not(debug_assertions))] -const DEFAULT_SLOT_MS: u64 = 5000; // 5 seconds for release - -/// CLI arguments for the NilCC simulator -#[derive(Parser, Debug)] -#[command(name = "nilcc_simulator")] -#[command(about = "blacklight Server - Submits HTXs to the smart contract", long_about = None)] -pub struct CliArgs { - #[clap(flatten)] - pub chain_args: ChainArgs, - - /// Private key for signing transactions - #[arg(long, env = "PRIVATE_KEY")] - pub private_key: Option, - - /// Path to HTXs JSON file - #[arg(long, env = "HTXS_PATH")] - pub htxs_path: Option, -} - -/// Simulator configuration with all required values resolved -#[derive(Debug, Clone)] -pub struct SimulatorConfig { - pub rpc_url: String, - pub manager_contract_address: Address, - pub staking_contract_address: Address, - pub token_contract_address: Address, - pub private_key: String, - pub htxs_path: String, - pub slot_ms: u64, -} - -impl SimulatorConfig { - /// Load configuration with priority: CLI/env -> state file -> defaults - pub fn load(cli_args: CliArgs) -> Result { - let state_file = StateFile::new(STATE_FILE_SIMULATOR); - let ChainConfig { - rpc_url, - manager_contract_address, - staking_contract_address, - token_contract_address, - } = ChainConfig::new(cli_args.chain_args, &state_file)?; - - // Load private key with priority (different default than node) - let private_key = cli_args - .private_key - .or_else(|| state_file.load_value("PRIVATE_KEY")) - .unwrap_or_else(|| { - "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() - }); - - // Load HTXs path with priority - let htxs_path = cli_args - .htxs_path - .or_else(|| state_file.load_value("HTXS_PATH")) - .unwrap_or_else(|| DEFAULT_HTXS_PATH.to_string()); - - info!( - "Loaded SimulatorConfig: rpc_url={rpc_url}, manager_contract_address={manager_contract_address}, htxs_path={htxs_path}" - ); - Ok(SimulatorConfig { - rpc_url, - manager_contract_address, - staking_contract_address, - token_contract_address, - private_key, - htxs_path, - slot_ms: DEFAULT_SLOT_MS, - }) - } -} diff --git a/nilcc-simulator/src/main.rs b/nilcc-simulator/src/main.rs deleted file mode 100644 index 4a125dd..0000000 --- a/nilcc-simulator/src/main.rs +++ /dev/null @@ -1,163 +0,0 @@ -use anyhow::Result; -use args::{CliArgs, SimulatorConfig}; -use blacklight_contract_clients::{ - htx::{Htx, NillionHtx, PhalaHtx}, - {BlacklightClient, ContractConfig}, -}; -use clap::Parser; -use rand::Rng; -use std::sync::Arc; -use std::time::Duration; -use tokio::time::interval; -use tracing::{error, info, warn}; -use tracing_subscriber::{EnvFilter, fmt, prelude::*}; - -mod args; - -#[tokio::main] -async fn main() -> Result<()> { - init_tracing(); - - let config = load_config()?; - let client = setup_client(&config).await?; - let htxs: Vec = load_htxs(&config.htxs_path); - - run_submission_loop(client, htxs, config.slot_ms).await -} - -fn init_tracing() { - tracing_subscriber::registry() - .with(fmt::layer().with_ansi(true)) - .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) - .init(); -} - -fn load_config() -> Result { - let cli_args = CliArgs::parse(); - let config = SimulatorConfig::load(cli_args)?; - info!(slot_ms = config.slot_ms, "Configuration loaded"); - Ok(config) -} - -async fn setup_client(config: &SimulatorConfig) -> Result { - let contract_config = ContractConfig::new( - config.rpc_url.clone(), - config.manager_contract_address, - config.staking_contract_address, - config.token_contract_address, - ); - - let client = BlacklightClient::new(contract_config, config.private_key.clone()).await?; - - info!( - contract = %client.manager.address(), - signer = %client.signer_address(), - "Connected to contract" - ); - - Ok(client) -} - -fn load_htxs(path: &str) -> Vec { - let htxs_json = std::fs::read_to_string(path).unwrap_or_else(|_| "[]".to_string()); - let htxs: Vec = serde_json::from_str(&htxs_json).unwrap_or_default(); - - if htxs.is_empty() { - warn!(path = %path, "No HTXs loaded"); - } else { - info!(count = htxs.len(), path = %path, "HTXs loaded"); - } - - htxs -} - -async fn run_submission_loop(client: BlacklightClient, htxs: Vec, slot_ms: u64) -> Result<()> { - let mut ticker = interval(Duration::from_millis(slot_ms)); - let mut slot = 0u64; - let client = Arc::new(client); - let htxs = Arc::new(htxs); - - loop { - ticker.tick().await; - slot += 1; - - // Spawn submission as a background task so it doesn't block the next slot - let client = Arc::clone(&client); - let htxs = Arc::clone(&htxs); - tokio::spawn(async move { - if let Err(e) = submit_next_htx(&client, &htxs, slot).await { - error!(slot, error = %e, "Submission failed"); - } - }); - } -} - -const MAX_RETRIES: u32 = 3; -const RETRY_DELAY_MS: u64 = 500; - -async fn submit_next_htx( - client: &Arc, - htxs: &Arc>, - slot: u64, -) -> Result<()> { - if htxs.is_empty() { - warn!(slot, "No HTXs available"); - return Ok(()); - } - - let node_count = client.manager.node_count().await?; - if node_count.is_zero() { - warn!(slot, "No nodes registered"); - return Ok(()); - } - - let mut last_error = None; - - for attempt in 0..MAX_RETRIES { - // Randomly select an HTX and make it unique by appending a random nonce to workload_id - // This prevents "HTX already exists" errors when multiple submissions land in the same block - // Scope rng to drop it before await (ThreadRng is not Send) - let htx = { - let mut rng = rand::rng(); - let idx = rng.random_range(0..htxs.len()); - let nonce: u128 = rng.random_range(0..u128::MAX); // 128-bit random number - let mut htx = htxs[idx].clone(); - match &mut htx { - Htx::Nillion(NillionHtx::V1(htx)) => { - htx.workload_id.current = format!("{}-{:x}", htx.workload_id.current, nonce); - } - Htx::Phala(PhalaHtx::V1(htx)) => { - htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); - } - } - htx - }; - - if attempt == 0 { - info!(slot, node_count = %node_count, "Submitting HTX"); - } else { - info!(slot, attempt, "Retrying HTX submission"); - } - - match client.manager.submit_htx(&htx).await { - Ok(tx_hash) => { - info!(slot, tx_hash = ?tx_hash, "HTX submitted"); - return Ok(()); - } - Err(e) => { - let error_str = e.to_string(); - // Only retry on on-chain reverts (state race conditions) - if error_str.contains("reverted on-chain") { - warn!(slot, attempt, error = %e, "Submission reverted, will retry"); - last_error = Some(e); - tokio::time::sleep(Duration::from_millis(RETRY_DELAY_MS)).await; - continue; - } - // For other errors (simulation failures, etc.), fail immediately - return Err(e); - } - } - } - - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("Max retries exceeded"))) -} diff --git a/nilcc-simulator/Cargo.toml b/simulator/Cargo.toml similarity index 83% rename from nilcc-simulator/Cargo.toml rename to simulator/Cargo.toml index 7f80216..42e0137 100644 --- a/nilcc-simulator/Cargo.toml +++ b/simulator/Cargo.toml @@ -1,18 +1,20 @@ [package] -name = "nilcc-simulator" +name = "simulator" version = "0.1.0" edition = "2024" [dependencies] alloy = { version = "1.1", features = ["contract", "providers"] } anyhow = "1.0" +async-trait = "0.1" clap = { version = "4.5", features = ["derive", "env"] } -serde_json = "1.0" rand = "0.9" +serde_json = "1.0" tokio = { version = "1.40", features = ["macros", "rt-multi-thread"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } blacklight-contract-clients = { path = "../crates/blacklight-contract-clients" } chain-args = { path = "../crates/chain-args" } +erc-8004-contract-clients = { path = "../crates/erc-8004-contract-clients" } state-file = { path = "../crates/state-file" } diff --git a/simulator/src/common.rs b/simulator/src/common.rs new file mode 100644 index 0000000..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..f0f22a9 --- /dev/null +++ b/simulator/src/erc8004.rs @@ -0,0 +1,241 @@ +use alloy::primitives::{Address, B256, U256, keccak256}; +use anyhow::Result; +use clap::Args; +use erc_8004_contract_clients::{ContractConfig, Erc8004Client}; +use state_file::StateFile; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; + +const STATE_FILE_SIMULATOR: &str = "erc_8004_simulator.env"; + +#[derive(Args, Debug)] +#[command(about = "Register agents and submit ERC-8004 validation requests")] +pub struct Erc8004Args { + /// RPC URL for the Ethereum node + #[arg(long, env = "RPC_URL")] + pub rpc_url: Option, + + /// Address of the IdentityRegistry contract + #[arg(long, env = "IDENTITY_REGISTRY_CONTRACT_ADDRESS")] + pub identity_registry_contract_address: Option, + + /// Address of the ValidationRegistry contract + #[arg(long, env = "VALIDATION_REGISTRY_CONTRACT_ADDRESS")] + pub validation_registry_contract_address: Option, + + /// Private key for signing transactions + #[arg(long, env = "PRIVATE_KEY")] + pub private_key: Option, + + /// Agent URI to register with + #[arg(long, env = "AGENT_URI")] + pub agent_uri: Option, + + /// 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..dd26f64 --- /dev/null +++ b/simulator/src/main.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use clap::Parser; +use tracing_subscriber::{EnvFilter, fmt, prelude::*}; + +mod common; +mod erc8004; +mod nilcc; + +#[derive(Parser, Debug)] +#[command(name = "simulator")] +#[command(about = "Blacklight simulators: HTX submission (nilcc) and ERC-8004 validation requests", long_about = None)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(clap::Subcommand, Debug)] +enum Command { + /// Submit HTXs to the HeartbeatManager contract (nilCC attestations) + Nilcc(nilcc::NilccArgs), + /// Register agents and submit ERC-8004 validation requests + Erc8004(erc8004::Erc8004Args), +} + +fn init_tracing() { + tracing_subscriber::registry() + .with(fmt::layer().with_ansi(true)) + .with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing(); + + let cli = Cli::parse(); + match cli.command { + Command::Nilcc(args) => common::run_simulator::(args).await, + Command::Erc8004(args) => common::run_simulator::(args).await, + } +} diff --git a/simulator/src/nilcc.rs b/simulator/src/nilcc.rs new file mode 100644 index 0000000..5dc5735 --- /dev/null +++ b/simulator/src/nilcc.rs @@ -0,0 +1,206 @@ +use alloy::primitives::Address; +use anyhow::Result; +use blacklight_contract_clients::{ + BlacklightClient, ContractConfig, + htx::{Htx, JsonHtx, NillionHtx, PhalaHtx}, +}; +use chain_args::{ChainArgs, ChainConfig}; +use clap::Args; +use rand::Rng; +use state_file::StateFile; +use std::sync::Arc; +use tracing::{info, warn}; + +use crate::common::{DEFAULT_SLOT_MS, Simulator, retry_submit}; + +const STATE_FILE_SIMULATOR: &str = "nilcc_simulator.env"; + +/// Default path to HTXs JSON file +const DEFAULT_HTXS_PATH: &str = "data/htxs.json"; + +#[derive(Args, Debug)] +#[command(about = "Submit HTXs to the HeartbeatManager contract")] +pub struct NilccArgs { + #[command(flatten)] + pub chain_args: ChainArgs, + + /// Private key for signing transactions + #[arg(long, env = "PRIVATE_KEY")] + pub private_key: Option, + + /// Path to HTXs JSON file + #[arg(long, env = "HTXS_PATH")] + pub htxs_path: Option, +} + +#[derive(Debug, Clone)] +pub struct NilccConfig { + pub rpc_url: String, + pub manager_contract_address: Address, + pub staking_contract_address: Address, + pub token_contract_address: Address, + pub private_key: String, + pub htxs_path: String, + pub slot_ms: u64, +} + +pub struct NilccSimulator { + client: Arc, + htxs: Arc>, + slot_ms: u64, +} + +impl NilccConfig { + pub fn load(args: NilccArgs) -> Result { + let state_file = StateFile::new(STATE_FILE_SIMULATOR); + let ChainConfig { + rpc_url, + manager_contract_address, + staking_contract_address, + token_contract_address, + } = ChainConfig::new(args.chain_args, &state_file)?; + + let private_key = args + .private_key + .or_else(|| state_file.load_value("PRIVATE_KEY")) + .unwrap_or_else(|| { + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a".to_string() + }); + + let htxs_path = args + .htxs_path + .or_else(|| state_file.load_value("HTXS_PATH")) + .unwrap_or_else(|| DEFAULT_HTXS_PATH.to_string()); + + info!( + "Loaded NilccConfig: rpc_url={rpc_url}, manager_contract_address={manager_contract_address}, htxs_path={htxs_path}" + ); + Ok(Self { + rpc_url, + manager_contract_address, + staking_contract_address, + token_contract_address, + private_key, + htxs_path, + slot_ms: DEFAULT_SLOT_MS, + }) + } +} + +fn load_htxs(path: &str) -> Vec { + let htxs_json = std::fs::read_to_string(path).unwrap_or_else(|_| "[]".to_string()); + let json_htxs: Vec = serde_json::from_str(&htxs_json).unwrap_or_default(); + let htxs: Vec = json_htxs.into_iter().map(Htx::from).collect(); + + if htxs.is_empty() { + warn!(path = %path, "No HTXs loaded"); + } else { + info!(count = htxs.len(), path = %path, "HTXs loaded"); + } + + htxs +} + +impl NilccSimulator { + async fn submit_next_htx(&self, slot: u64) -> Result<()> { + let client = Arc::clone(&self.client); + let htxs = Arc::clone(&self.htxs); + + if htxs.is_empty() { + warn!(slot, "No HTXs available"); + return Ok(()); + } + + let node_count = client.manager.node_count().await?; + if node_count.is_zero() { + warn!(slot, "No nodes registered"); + return Ok(()); + } + + retry_submit( + move |attempt| { + let client = Arc::clone(&client); + let htxs = Arc::clone(&htxs); + async move { + let htx = { + let mut rng = rand::rng(); + let idx = rng.random_range(0..htxs.len()); + let nonce: u128 = rng.random_range(0..u128::MAX); + let mut htx = htxs[idx].clone(); + match &mut htx { + Htx::Nillion(NillionHtx::V1(htx)) => { + htx.workload_id.current = + format!("{}-{:x}", htx.workload_id.current, nonce); + } + Htx::Phala(PhalaHtx::V1(htx)) => { + htx.app_compose = format!("{}-{:x}", htx.app_compose, nonce); + } + Htx::Erc8004(_) => { + unreachable!("ERC-8004 HTXs should not be loaded from JSON files") + } + } + htx + }; + + if attempt == 0 { + info!(slot, node_count = %node_count, "Submitting HTX"); + } else { + info!(slot, attempt, "Retrying HTX submission"); + } + + let tx_hash = client.manager.submit_htx(&htx).await?; + info!(slot, tx_hash = ?tx_hash, "HTX submitted"); + Ok(()) + } + }, + move |attempt, error| { + warn!(slot, attempt, error = %error, "Submission reverted, will retry"); + }, + ) + .await + } +} + +#[async_trait::async_trait] +impl Simulator for NilccSimulator { + type Args = NilccArgs; + + async fn build(args: Self::Args) -> Result { + let config = NilccConfig::load(args)?; + info!(slot_ms = config.slot_ms, "Configuration loaded"); + + let contract_config = ContractConfig::new( + config.rpc_url.clone(), + config.manager_contract_address, + config.staking_contract_address, + config.token_contract_address, + ); + + let client = BlacklightClient::new(contract_config, config.private_key.clone()).await?; + info!( + contract = %client.manager.address(), + signer = %client.signer_address(), + "Connected to contract" + ); + + let htxs = load_htxs(&config.htxs_path); + + Ok(Self { + client: Arc::new(client), + htxs: Arc::new(htxs), + slot_ms: config.slot_ms, + }) + } + + fn slot_ms(&self) -> u64 { + self.slot_ms + } + + fn submission_error_message(&self) -> &'static str { + "NilCC submission failed" + } + + async fn on_tick(&self, slot: u64) -> Result<()> { + self.submit_next_htx(slot).await + } +}