Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,5 @@ coverage/

.claude/

blacklight_node/
blacklight_node/
CLAUDE.md
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
21 changes: 19 additions & 2 deletions blacklight-node/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Htx>(&event.rawHTX) {
// Debug: log the raw HTX bytes
let raw_bytes: &[u8] = &event.rawHTX;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Won't raw_bytes be unused if debug isn't set? Doesn't clippy complain here? I might be wrong, not sure how tracing exptects things.

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");
Expand All @@ -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");
Expand Down
50 changes: 47 additions & 3 deletions blacklight-node/src/verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -31,6 +31,7 @@ pub enum VerificationError {
PhalaEventLogParse(String),
FetchCerts(String),
DetectProcessor(String),
Erc8004FetchUri(String),

// Malicious errors - cryptographic verification failures
VerifyReport(String),
Expand All @@ -39,6 +40,7 @@ pub enum VerificationError {
PhalaComposeHashMismatch,
PhalaQuoteVerify(String),
InvalidCertificate(String),
Erc8004InvalidUri(String),
}

impl VerificationError {
Expand All @@ -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,
}
}

Expand All @@ -92,13 +96,15 @@ 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}"),
MeasurementHash(e) => format!("measurement hash verification failed: {e}"),
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}"),
}
}
}
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions crates/blacklight-contract-clients/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
72 changes: 28 additions & 44 deletions crates/blacklight-contract-clients/src/blacklight_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DynProvider>,
pub token: NilTokenClient<DynProvider>,
pub staking: StakingOperatorsClient<DynProvider>,
Expand All @@ -25,26 +20,26 @@ pub struct BlacklightClient {

impl BlacklightClient {
pub async fn new(config: ContractConfig, private_key: String) -> anyhow::Result<Self> {
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::<PrivateKeySigner>()?;
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();
Comment on lines -28 to -45
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we changing this? Seems orthogonal to the ERC-8004 changes.

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<Self> {
let provider = ctx.provider().clone();
let tx_lock = ctx.tx_lock();

// Instantiate contract clients using the shared provider
let manager =
Expand All @@ -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,
Expand All @@ -68,31 +62,21 @@ impl BlacklightClient {

/// Get the signer address
pub fn signer_address(&self) -> Address {
<EthereumWallet as NetworkWallet<Ethereum>>::default_signer_address(&self.wallet)
self.ctx.signer_address()
}

/// Get the balance of the wallet
pub async fn get_balance(&self) -> anyhow::Result<U256> {
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<U256> {
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<B256> {
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
}
}
22 changes: 0 additions & 22 deletions crates/blacklight-contract-clients/src/common/mod.rs
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this unused?

This file was deleted.

8 changes: 3 additions & 5 deletions crates/blacklight-contract-clients/src/heartbeat_manager.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
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,
sol,
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;

Expand Down
Loading