Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4abd97b
feat: integrate ERC 8004 HTX parsing and validation
jcabrero Jan 30, 2026
9a59f7b
refactor: extract shared contract client utilities into common crate
jcabrero Feb 2, 2026
4c8b082
chore: unified simulators under a single trait
jcabrero Feb 2, 2026
ec6856a
chore: cleaned up htx validation for ERC-8004
jcabrero Feb 2, 2026
a71cdae
chore: move common contract utilities to a single crate
jcabrero Feb 4, 2026
1f111d4
feat: unified contract clients
jcabrero Feb 4, 2026
fcbe69e
feat: embedded validation responses on keeper
jcabrero Feb 2, 2026
fc0722a
fix: improved code structure
jcabrero Feb 4, 2026
cd5bad6
feat: improved logging and codebase
jcabrero Feb 5, 2026
9e98249
fix: replace notify with cancellation signal
jcabrero Jan 29, 2026
96c595d
chore: refactored and simplified blacklight-node code
jcabrero Jan 29, 2026
8ed84c7
chore: substitute DynSolTypes with SolTypes and resturcture HTX parsing
jcabrero Feb 6, 2026
6acfe4e
chore: use more idiomatic bail!()
jcabrero Feb 6, 2026
ddbb9e6
fix: incorrect rebase
jcabrero Feb 6, 2026
212fe01
fix: merged event L2 metrics with ERC 8004
jcabrero Feb 6, 2026
abef85b
Merge pull request #72 from NillionNetwork/fix/erc_8004_fixes
jcabrero Feb 6, 2026
8274738
Merge pull request #71 from NillionNetwork/chore/restructured_node
jcabrero Feb 6, 2026
4d61e14
Merge pull request #70 from NillionNetwork/feat/erc_8004_validations_…
jcabrero Feb 6, 2026
565b9da
fix: code organization and commenting
jcabrero Feb 6, 2026
07752a3
fix: adjusted docker compose number of accounts
jcabrero Feb 6, 2026
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
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, this is solved in the follow up PR.

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
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This comes related to your previous comment on removing the common/ subpath. I created a new crate to integrate with ERC 8004 smart contracts. That crate uses the same common modules to submit transactions and interact with the chain. In order to avoid repetitions in the code, I moved this to its own crate that is shared by the two other crates. Another reason to make this code common is: when you interact with a smart contract to submit transactions, the nonce is fundamental for transactions to be accepted, thus the use of the same lock is necessary to avoid transactions frontrunning others. That's why I thought the best idea was to unify the interfaces in ProviderContext.

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
Copy Markdown
Contributor

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