From hl-design-systems
Horizen Labs Agent Marketplace integration on Base Sepolia — full pipeline from ZK proof submission (Kurier) through attestation relay to on-chain validation recording. Covers agent registration (IdentityRegistry), validation recording (ValidationGateway), attestation polling, and marketplace discovery.
npx claudepluginhub horizenlabs/hl-claude-marketplace --plugin hl-design-systemsThis skill uses the workspace's default tool permissions.
Register AI agents on-chain and record ZK-verified validations on the HL Agent Marketplace (Base Sepolia).
Guides Next.js Cache Components and Partial Prerendering (PPR) with cacheComponents enabled. Implements 'use cache', cacheLife(), cacheTag(), revalidateTag(), static/dynamic optimization, and cache debugging.
Migrates code, prompts, and API calls from Claude Sonnet 4.0/4.5 or Opus 4.1 to Opus 4.5, updating model strings on Anthropic, AWS, GCP, Azure platforms.
Performs token-optimized structural code search using tree-sitter AST parsing to discover symbols, outline files, and unfold code without reading full files.
Register AI agents on-chain and record ZK-verified validations on the HL Agent Marketplace (Base Sepolia).
The HL marketplace is a set of smart contracts on Base Sepolia that enable AI agents to:
1. Generate proof (e.g., Groth16, PLONK, etc.)
2. Submit to Kurier with chainId: 84532 (Base Sepolia)
3. Poll Kurier until status = "Aggregated"
4. Extract aggregationId + aggregationDetails from Kurier response
5. Wait for attestation relay to Base Sepolia (poll proofsAggregations)
6. Call ValidationGateway.recordValidation() with protocol fee
7. Agent visible at: https://agent-registry.horizenlabs.io/agent/{tokenIdHex}
| Contract | Address | Purpose |
|---|---|---|
| IdentityRegistry | 0x8004A818BFB912233c491871b3d84c89A494BD9e | ERC-721 AgentCards — register agents |
| ValidationGateway (V2 proxy) | 0xbbdcb0C9C3B9ce60555fdF50cFB99802E7c33920 | Record validations (V2 — UUPS proxy, always call this address) |
| ValidationGateway (V2 impl, current) | 0x3995875565be8e354f17dbfc3f300fc450157bb2 | V2 implementation — never call directly. Check EIP-1967 slot 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc for current impl — HL has upgraded it (was 0x7d9f3830...) |
| ValidationGateway (V1) | 0xD0248DAF7f362F7721EC77b9A7A74d9A8FEe361e | DEPRECATED — registry ownership moved to V2 (reverts with "Not owner") |
| ValidationRegistry | 0x75a7f712635D7918563659795450ddE6751D71BC | Immutable validation storage (ownership held by V2 proxy) |
| zkVerify Attestation | 0x0807C544D38aE7729f8798388d89Be6502A1e8A8 | Proof aggregation relay from zkVerify |
V2 Migration (March 30 2026, upgraded April 2026): HL deployed ValidationGatewayV2 as a new UUPS proxy and transferred ValidationRegistry ownership to it. The V1 gateway is bricked. The V2 implementation was then upgraded to add generic proof-type support (multi-verifier) and
versionHashin the struct. Source: github.com/HorizenLabs/ai-agent-registry.Current ABI (function selector
0x27d6cd1c):
(agentId, proofType, zkVerifyTxHash, zkVerifyBlockHash, domainId, attestationId, leaf, merklePath, leafCount, index, pubsBytes, vkHash, versionHash)pubsBytes: bytes— raw LE-encoded public signals (wasuint256[]in the first V2 iteration)versionHash: bytes32— for snarkjs/Groth16:SHA256("") = 0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855Key V2 gates:
- Proof type config:
proofTypeis a service-metric identifier (e.g."pulse-sla","aideal-mock") registered by the gateway owner viasetProofTypeConfig(proofType, ctxHash, agentIdOffset, agentIdLength, agentIdLittleEndian, active). For snarkjs Groth16:ctxHash = keccak256("groth16"),agentIdOffset=0, agentIdLength=32, agentIdLittleEndian=true. Without an active config,"Unsupported proof type".- Submitter authorization: Caller must be in
authorizedSubmitters[msg.sender]OR the ERC-8004 owner ofagentId. Gateway owner authorizes viasetAuthorizedSubmitter(wallet, true). Without this,"Not authorized submitter".- VK hash gating:
allowedVkHashes[vkHash]must betrue(set viasetVkHash(hash, true)). Without this,"VK hash not allowed".- agentId binding: Circuit must have
agentIdas the first public signal (first 32 bytes LE ofpubsBytes). Gateway extracts and matches againstp.agentId. Without this,"Agent ID mismatch".- Leaf reconstruction: Gateway recomputes
keccak256(ctxHash ‖ vkHash ‖ versionHash ‖ keccak256(pubsBytes))and requires it to matchleaf. Without this,"Leaf mismatch".- Replay protection: Each
(attestationId, leaf)pair usable once —"Attestation already used"on reuse.- Fee: Must send
>= protocolFee()asmsg.value(currently 0.0001 ETH on testnet). Refunds any excess.pubsBytes encoding: For snarkjs/Groth16, concatenate each public signal as a 32-byte little-endian scalar. Example for signals
["2969", "385"]:function encodePubsBytes(signals) { const buffers = []; for (const sig of signals) { const buf = Buffer.alloc(32); let v = BigInt(sig); for (let i = 0; i < 32 && v > 0n; i++) { buf[i] = Number(v & 0xffn); v >>= 8n; } buffers.push(buf); } return "0x" + Buffer.concat(buffers).toString("hex"); }Full registration checklist (3 admin calls by gateway owner):
setProofTypeConfig("<your-proof-type>", keccak256("groth16"), 0, 32, true, true)setVkHash(<yourVkHash>, true)setAuthorizedSubmitter(<yourWallet>, true)(unless you own the agentId in the ERC-8004 registry)How to compute vkHash (Groth16/snarkjs): Call Kurier's
POST /api/v1/register-vk/{API_KEY}with{ proofType: "groth16", proofOptions: { library: "snarkjs", curve: "bn128" }, vk: <yourVk> }— it returns the canonicalvkHashzkVerify uses for leaf construction.Sanity-check before
recordValidation— ALL three must be true:await readContract(gateway, "allowedVkHashes", [vkHash]) // → true await readContract(gateway, "authorizedSubmitters", [yourWallet]) // → true const cfg = await readContract(gateway, "proofTypeConfigs", [proofType]) cfg.ctxHash === keccak256("groth16") && cfg.active === true // → trueAny being
falsemeans silent revert with no error data on-chain. Always verify all three before submitting.
npm install viem
Wallet requirements:
Environment variables:
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
PRIVATE_KEY=0x... # Wallet with Base Sepolia ETH
AGENT_CARD_TOKEN_ID= # Set after registration (step 2)
KURIER_API_KEY= # From https://kurier.xyz
KURIER_API_URL=https://api-testnet.kurier.xyz # MUST use testnet for Base Sepolia
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { baseSepolia } from "viem/chains";
const rpcUrl = process.env.BASE_SEPOLIA_RPC_URL || "https://sepolia.base.org";
const publicClient = createPublicClient({
chain: baseSepolia,
transport: http(rpcUrl),
});
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
chain: baseSepolia,
transport: http(rpcUrl),
});
const IDENTITY_REGISTRY = "0x8004A818BFB912233c491871b3d84c89A494BD9e";
const registerAbi = [
{
inputs: [{ name: "agentURI", type: "string" }],
name: "register",
outputs: [{ name: "agentId", type: "uint256" }],
stateMutability: "nonpayable",
type: "function",
},
{
anonymous: false,
inputs: [
{ indexed: true, name: "agentId", type: "uint256" },
{ indexed: false, name: "agentURI", type: "string" },
{ indexed: true, name: "owner", type: "address" },
],
name: "Registered",
type: "event",
},
] as const;
// Encode metadata as base64 data URI
const metadata = {
name: "My Agent",
description: "What my agent does",
services: [{
name: "my-service",
endpoint: "https://my-agent.example.com/api",
version: "0.1.0",
skills: ["skill-1", "skill-2"],
domains: ["domain-1"],
}],
supportedTrust: ["zkVerify-groth16"],
metadata: {
proofSystem: "groth16",
library: "snarkjs",
curve: "bn128",
},
};
const agentURI = `data:application/json;base64,${Buffer.from(JSON.stringify(metadata)).toString("base64")}`;
// IMPORTANT: Use register(), NOT safeMint or mint
const txHash = await walletClient.writeContract({
address: IDENTITY_REGISTRY,
abi: registerAbi,
functionName: "register",
args: [agentURI],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
// Extract agentId from Registered event
import { parseEventLogs } from "viem";
const logs = parseEventLogs({ abi: registerAbi, logs: receipt.logs });
const event = logs.find((l) => l.eventName === "Registered");
const agentId = event?.args?.agentId; // e.g., 2094n
// Set AGENT_CARD_TOKEN_ID to this value in your .env
// Agent page: https://agent-registry.horizenlabs.io/agent/{agentId in hex}
Submit a ZK proof to Kurier for verification and aggregation. Kurier handles submission to zkVerify and aggregation for relay to Base Sepolia.
import axios from "axios";
const KURIER_API_URL = process.env.KURIER_API_URL; // https://api-testnet.kurier.xyz
const KURIER_API_KEY = process.env.KURIER_API_KEY;
const submitRes = await axios.post(
`${KURIER_API_URL}/api/v1/submit-proof/${KURIER_API_KEY}`,
{
proofType: "groth16", // or other supported proof types
vkRegistered: false, // true if VK is pre-registered on zkVerify
chainId: 84532, // Base Sepolia — required for attestation relay
proofOptions: { library: "snarkjs", curve: "bn128" },
proofData: {
proof: yourProof, // proof object from snarkjs
publicSignals: yourSignals,
vk: yourVerificationKey, // required if vkRegistered: false
},
}
);
const jobId = submitRes.data.jobId;
let aggregationId: number;
let aggregationDetails: Record<string, unknown>;
for (let i = 0; i < 180; i++) { // up to 15 min
await new Promise((r) => setTimeout(r, 5000));
const statusRes = await axios.get(
`${KURIER_API_URL}/api/v1/job-status/${KURIER_API_KEY}/${jobId}`
);
const { status, ...data } = statusRes.data;
if (status === "Failed") throw new Error("Proof verification failed");
if (status === "Aggregated" || status === "AggregationPublished") {
aggregationId = data.aggregationId; // top-level sequential integer
aggregationDetails = data.aggregationDetails;
break;
}
}
Kurier status lifecycle (with chainId set — the aggregation path):
[Queued → Valid] → IncludedInBlock → AggregationPending → Aggregated → (AggregationPublished)
Queued / Valid may not be observed at a 5s polling cadence — the proof typically reaches IncludedInBlock within the first poll.AggregationPending is the longest-lived state (~1–3 minutes on Base Sepolia testnet).Aggregated — aggregationDetails is now populated with the Merkle proof you need for recordValidation.Observed timing (Base Sepolia, April 2026): submit → Aggregated ≈ 3 minutes. Attestation relay to Base ≈ 0–5 min after that. Full pipeline (submit → recordValidation mined): ~3–5 min.
Important: With chainId set you do NOT see Finalized in the aggregation path — it goes straight from AggregationPending to Aggregated. Do not wait for Finalized.
After Kurier reports Aggregated, the attestation must be relayed to Base Sepolia. This typically takes 1-5 minutes.
const ATTESTATION_ADDRESS = "0x0807C544D38aE7729f8798388d89Be6502A1e8A8";
const attestationAbi = [{
inputs: [
{ name: "_domainId", type: "uint256" },
{ name: "_aggregationId", type: "uint256" },
],
name: "proofsAggregations",
outputs: [{ name: "", type: "bytes32" }],
stateMutability: "view",
type: "function",
}] as const;
async function waitForRelay(domainId: bigint, aggregationId: number): Promise<boolean> {
for (let i = 0; i < 60; i++) { // up to 10 min
const root = await publicClient.readContract({
address: ATTESTATION_ADDRESS,
abi: attestationAbi,
functionName: "proofsAggregations",
args: [domainId, BigInt(aggregationId)],
});
if (root !== "0x" + "0".repeat(64)) return true;
await new Promise((r) => setTimeout(r, 10_000));
}
return false; // Timed out — save data and retry later
}
// domainId corresponds to the destination chain, NOT the proof type
// For Base Sepolia testnet: domainId = 2
const relayed = await waitForRelay(2n, aggregationId);
if (!relayed) throw new Error("Attestation relay timed out");
Important:
domainIdidentifies the destination chain for the attestation relay, not the proof system. For Base Sepolia, usedomainId = 2. This value is the same regardless of whether you submit a Groth16, PLONK, or any other proof type.
// V2 UUPS proxy — always call this address.
const VALIDATION_GATEWAY = "0xbbdcb0C9C3B9ce60555fdF50cFB99802E7c33920";
// SHA256("") — versionHash for snarkjs/Groth16 proofs (V2 requirement)
const VERSION_HASH = "0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
const gatewayAbi = [
{
inputs: [{
name: "p",
type: "tuple",
components: [
{ name: "agentId", type: "uint256" },
{ name: "proofType", type: "string" }, // Service-metric identifier (e.g. "pulse-sla", "aideal-mock")
{ name: "zkVerifyTxHash", type: "bytes32" }, // extrinsic hash from Kurier
{ name: "zkVerifyBlockHash", type: "bytes32" }, // block hash from Kurier
{ name: "domainId", type: "uint256" },
{ name: "attestationId", type: "uint256" },
{ name: "leaf", type: "bytes32" },
{ name: "merklePath", type: "bytes32[]" },
{ name: "leafCount", type: "uint256" },
{ name: "index", type: "uint256" },
{ name: "pubsBytes", type: "bytes" }, // Raw LE-encoded public signals (NOT uint256[])
{ name: "vkHash", type: "bytes32" }, // Canonical zkVerify VK hash (must be allowlisted)
{ name: "versionHash", type: "bytes32" }, // SHA256("") for Groth16
],
}],
name: "recordValidation",
outputs: [{ name: "validationId", type: "uint256" }],
stateMutability: "payable",
type: "function",
},
{ inputs: [], name: "protocolFee", outputs: [{ name: "", type: "uint256" }], stateMutability: "view", type: "function" },
{ inputs: [{ name: "", type: "bytes32" }], name: "allowedVkHashes", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" },
{ inputs: [{ name: "", type: "address" }], name: "authorizedSubmitters", outputs: [{ name: "", type: "bool" }], stateMutability: "view", type: "function" },
{ inputs: [{ name: "", type: "string" }], name: "proofTypeConfigs", outputs: [{ name: "ctxHash", type: "bytes32" }, { name: "agentIdOffset", type: "uint256" }, { name: "agentIdLength", type: "uint256" }, { name: "agentIdLittleEndian", type: "bool" }, { name: "active", type: "bool" }], stateMutability: "view", type: "function" },
] as const;
// Encode public signals as raw LE 32-byte scalars (this is what zkVerify/Groth16 natively uses)
function encodePubsBytes(signals: string[]): `0x${string}` {
const buffers: Buffer[] = [];
for (const sig of signals) {
const buf = Buffer.alloc(32);
let v = BigInt(sig);
for (let i = 0; i < 32 && v > 0n; i++) { buf[i] = Number(v & 0xffn); v >>= 8n; }
buffers.push(buf);
}
return `0x${Buffer.concat(buffers).toString("hex")}`;
}
// Compute vkHash via Kurier register-vk:
const vkRes = await fetch(`${KURIER_API_URL}/api/v1/register-vk/${KURIER_API_KEY}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ proofType: "groth16", proofOptions: { library: "snarkjs", curve: "bn128" }, vk: yourVk }),
});
const { vkHash } = await vkRes.json();
// MANDATORY preflight — check ALL THREE gateway configs or get a silent revert:
const [vkAllowed, submitterAuth, ptCfg] = await Promise.all([
publicClient.readContract({ address: VALIDATION_GATEWAY, abi: gatewayAbi, functionName: "allowedVkHashes", args: [vkHash] }),
publicClient.readContract({ address: VALIDATION_GATEWAY, abi: gatewayAbi, functionName: "authorizedSubmitters", args: [walletClient.account.address] }),
publicClient.readContract({ address: VALIDATION_GATEWAY, abi: gatewayAbi, functionName: "proofTypeConfigs", args: ["your-proof-type"] }),
]);
if (!vkAllowed) throw new Error(`vkHash not allowlisted — ask owner: setVkHash(${vkHash}, true)`);
if (!submitterAuth) throw new Error(`Submitter not authorized — ask owner: setAuthorizedSubmitter(${walletClient.account.address}, true)`);
if (!ptCfg[4]) throw new Error(`Proof type not active — ask owner: setProofTypeConfig("your-proof-type", keccak256("groth16"), 0, 32, true, true)`);
const fee = await publicClient.readContract({
address: VALIDATION_GATEWAY, abi: gatewayAbi, functionName: "protocolFee",
}) as bigint;
const nonce = await publicClient.getTransactionCount({
address: walletClient.account.address, blockTag: "pending",
});
const pubsBytes = encodePubsBytes(yourPublicSignals); // same array sent to Kurier
const txHash = await walletClient.writeContract({
address: VALIDATION_GATEWAY, abi: gatewayAbi, functionName: "recordValidation",
args: [{
agentId: BigInt(process.env.AGENT_CARD_TOKEN_ID!),
proofType: "your-proof-type", // service-metric id, NOT "groth16"
zkVerifyTxHash: kurierTxHash as `0x${string}`,
zkVerifyBlockHash: kurierBlockHash as `0x${string}`,
domainId: 2n, // Base Sepolia
attestationId: BigInt(aggregationId),
leaf: aggregationDetails.leaf as `0x${string}`,
merklePath: aggregationDetails.merkleProof as `0x${string}`[],
leafCount: BigInt(aggregationDetails.numberOfLeaves),
index: BigInt(aggregationDetails.leafIndex),
pubsBytes,
vkHash: vkHash as `0x${string}`,
versionHash: VERSION_HASH as `0x${string}`,
}],
value: fee,
nonce,
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash });
// receipt.status === "success" → validation recorded on-chain
Common pitfalls learned the hard way:
- VK allowlist requests: Send the 32-byte hash from Kurier's
register-vkresponse, not a contract address. Verify withallowedVkHashes(yourVkHash)returnstruebefore the tx.- proofType is not the ZK system:
"groth16"goes to Kurier'sproofTypefield (ZK system). The gateway'sproofTypeis a service-metric identifier (e.g."pulse-sla","aideal-mock"). Using"groth16"for the gateway will revert with"Unsupported proof type".- Silent reverts with no error data usually mean ABI mismatch. The V2 gateway's implementation was upgraded (check EIP-1967 slot for current impl address) — the newer ABI uses
bytes pubsBytesinstead ofuint256[] publicSignalsand addsversionHash. Wrong ABI → wrong function selector → proxy reverts without a reason string.- Three admin calls are required, not just one:
setVkHash,setAuthorizedSubmitter,setProofTypeConfig. Check all three before asking users to debug.
| Kurier Response Field | Contract Parameter | Type | Notes |
|---|---|---|---|
aggregationId (top-level) | attestationId | uint256 | Sequential integer (e.g., 22435). NOT receipt (which is a Merkle root hash) |
Hardcode 2 for Base Sepolia | domainId | uint256 | Destination chain for attestation relay, not proof type |
aggregationDetails.leaf | leaf | bytes32 | Proof leaf in Merkle tree |
aggregationDetails.numberOfLeaves | leafCount | uint256 | Tree size |
aggregationDetails.leafIndex | index | uint256 | Leaf position |
aggregationDetails.merkleProof | merklePath | bytes32[] | Merkle inclusion proof |
When building a UI that shows verification progress to users, persist the pipeline status at each step so the frontend can poll and display a live timeline. Recommended DB fields and when to update them:
| Pipeline Step | Suggested Status | DB Fields to Update |
|---|---|---|
| Proof submitted to Kurier | SUBMITTED | zkVerifyJobId |
txHash appears in Kurier response | FINALIZED | zkVerifyExtrinsicHash (for zkVerify explorer link) |
Kurier returns Aggregated | AGGREGATED | aggregationId |
| Waiting for attestation relay | RELAYING | — |
recordValidation tx sent | RECORDING | — |
| Receipt confirmed | VERIFIED | validationTxHash, validationId |
| Any step throws | FAILED | onChainError (error message) |
Key design points:
zkVerifyExtrinsicHash early — it appears in the Kurier job-status response at IncludedInBlock or later (as txHash). This lets you show a zkVerify explorer link before aggregation completes.aggregationId separately — you need it for the attestation relay check and recordValidation call, but it's also useful for debugging.validationTxHash on success — this is the Base Sepolia transaction hash for the recordValidation call, used for BaseScan explorer links.Example Prisma schema fields:
model Report {
// ... your existing fields ...
onChainStatus String? // PENDING | SUBMITTED | FINALIZED | AGGREGATED | RELAYING | RECORDING | VERIFIED | FAILED
onChainError String?
zkVerifyJobId String? // Kurier job ID
zkVerifyExtrinsicHash String? // zkVerify blockchain tx hash (for explorer link)
aggregationId Int? // Kurier aggregation ID (= attestationId in contracts)
validationTxHash String? // Base Sepolia tx hash from recordValidation
validationId Int? // On-chain validation ID from ValidationGateway
}
Nonce provided for the transaction is lower than the current nonce)This happens when sending transactions in quick succession (e.g., register then immediately record a validation). Fix: always fetch the pending nonce before writeContract:
const nonce = await publicClient.getTransactionCount({
address: walletClient.account.address,
blockTag: "pending",
});
// pass nonce to writeContract
recordValidation revertsCommon causes:
proofsAggregations first, wait for non-zero returnattestationId — use the top-level aggregationId from Kurier (a sequential integer like 22435), NOT the receipt field (which is a bytes32 Merkle root)protocolFee() ETH with the call (currently 0.0002 ETH)domainId — use 2 for Base Sepolia testnethttps://api.kurier.xyz rejects chainId: 84532. Use https://api-testnet.kurier.xyz for Base SepoliaThe relay from zkVerify to Base Sepolia typically takes 1-5 minutes after Kurier reports Aggregated. If it takes longer:
aggregationId and aggregationDetails to diskrecordValidation later| Resource | URL Pattern |
|---|---|
| Agent page | https://agent-registry.horizenlabs.io/agent/{tokenIdHex} |
| BaseScan tx | https://sepolia.basescan.org/tx/{txHash} |
| BaseScan contract | https://sepolia.basescan.org/address/{address} |
| zkVerify Explorer | https://zkverify-testnet.subscan.io/extrinsic/{txHash} |
Token ID hex conversion: decimal 2094 → 0x82e → https://agent-registry.horizenlabs.io/agent/0x82e
See contracts.md for full ABI definitions and additional functions.