Skip to content

feat: add ERC-8004 HTX parsing/validation and consolidate contract client utilities#62

Open
jcabrero wants to merge 6 commits intomainfrom
feat/erc_8004_validations
Open

feat: add ERC-8004 HTX parsing/validation and consolidate contract client utilities#62
jcabrero wants to merge 6 commits intomainfrom
feat/erc_8004_validations

Conversation

@jcabrero
Copy link
Member

@jcabrero jcabrero commented Jan 30, 2026

This PR introduces support for verifying agents submited through ERC 8004. This works together with blacklight-contracts#4.

  • integrate ERC-8004 HTX parsing and validation flow from ABI encoded requests.
  • refactor shared contract client utilities into a common crate
    • because we created a separate client to interact with the ERC-8004 contracts, I unified common utils like tx_submitter in a different crate.
  • unify simulators under a single trait
    • The simulator that emits transactions for ERC 8004 and NilCC simulator work under the same trait under simulator/.

@jcabrero jcabrero force-pushed the feat/erc_8004_validations branch from befe7e1 to ec6856a Compare February 4, 2026 10:43
@jcabrero jcabrero changed the title feat: integrate ERC 8004 HTX parsing and validation feat: add ERC-8004 HTX parsing/validation and consolidate contract client utilities Feb 4, 2026
// 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.

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?

Comment on lines -28 to -45
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();
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.

Copy link
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.

/// ERC-8004 Validation HTX data parsed from ABI-encoded bytes.
/// Format: `abi.encode(validatorAddress, agentId, requestURI, requestHash)`
#[derive(Debug, Clone)]
pub struct Erc8004Htx {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: should we call this V1 as well?

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "camelCase")]
pub enum Htx {
pub enum JsonHtx {
Copy link
Member

Choose a reason for hiding this comment

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

Maybe TeeHtx or do want to have any JSON here?

#[test]
fn test_decode_error_string() {
// "blacklight: unknown HTX" encoded as Error(string)
// "NilAV: unknown HTX" encoded as Error(string)
Copy link
Member

Choose a reason for hiding this comment

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

This probably slipped.

// 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())
Copy link
Member

Choose a reason for hiding this comment

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

and this

Copy link
Member

Choose a reason for hiding this comment

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

Oh, I see you added the mod here. Is this change necessary?

# Anvil - Local Ethereum testnet
anvil:
image: ghcr.io/nillionnetwork/blacklight-contracts/anvil:sha-64cd680
image: nilanvil:latest
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 correct?

Copy link
Member Author

Choose a reason for hiding this comment

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

You're right. This is not correct. Because I created multiple PRs on top of each other, this is solved in the follow up PR.

networks:
- blacklight-network
command: ["--accounts", "15"] # Define the number of accounts to create
command: ["--accounts", "20"] # Define the number of accounts to create
Copy link
Member

Choose a reason for hiding this comment

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

since this is local maybe less accounts might be better to be more lightweight?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a good point, I am going to reduce the number.

impl Erc8004Htx {
/// Try to decode ABI-encoded ERC-8004 validation data.
pub fn try_decode(data: &[u8]) -> Result<Self, Erc8004DecodeError> {
let tuple_type = DynSolType::Tuple(vec![
Copy link
Member

Choose a reason for hiding this comment

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

Is maybe using SolType instead of DynSolType make this easier? I'd hope alloy provides a better way of doing this than trying to decode each tuple field by hand.

Comment on lines +110 to +112
Err(anyhow::anyhow!(
"Transfer event not found in transaction receipt"
))
Copy link
Member

Choose a reason for hiding this comment

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

nit: bail! (from anyhow) is a shorthand for this. e.g. bail!("beep failed")

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants