ASSAY — Technical Documentation
ASSAY is a ZK proof-of-performance protocol for AI trading agents on Robinhood Chain. It takes a wallet's real on-chain execution fills, runs them through a SP1 zkVM program, and writes a cryptographically-verified performance claim to an immutable registry contract — with the underlying strategy kept private.
Architecture
The system is composed of four independently-deployable layers that communicate through well-defined data interfaces:
Trust model
Every component is designed to eliminate self-reporting:
- Fill data — sourced exclusively from Blockscout's RHC API, which reads directly from chain state. No user-submitted data enters the ZK circuit.
- Computation — executed inside SP1's RISC-V zkVM. The program correctness is guaranteed by the cryptographic proof, not by trust in ASSAY.
- fillsAnchor — a
keccak256hash over the ordered list of(txHash, blockNumber)pairs, computed inside the circuit. This binds the proof to a specific set of on-chain transactions that anyone can re-verify. - Registry — an append-only Solidity contract. Claims are immutable once written. No admin key can delete or modify them.
Quickstart
Prerequisites: Node.js ≥ 20, pnpm ≥ 9, Rust 1.78+, Foundry, SP1 toolchain 6.3.1.
1. Clone and install
git clone https://github.com/your-org/assay
cd assay
pnpm install2. Configure environment
cp .env.example .env
# Fill in at minimum:
# DEPLOYER_PRIVATE_KEY — wallet with testnet ETH on RHC 46630
# BLOCKSCOUT_API_KEY — Blockscout Pro API key
# SP1_PROVER — "mock" for local testing, "network" for Succinct
# NETWORK_PRIVATE_KEY — wallet holding PROVE tokens (network mode only)
# NETWORK_RPC_URL — https://rpc.mainnet.succinct.xyz3. Deploy contracts (testnet)
cd contracts
# Deploy SP1 Plonk verifier
forge script script/DeploySP1PlonkVerifier.s.sol \
--rpc-url https://testnet.rpc.robinhood.com \
--broadcast --verify
# Deploy AssayRegistry
forge script script/DeployRegistry.s.sol \
--rpc-url https://testnet.rpc.robinhood.com \
--broadcast4. Build the prover binary
cd zk/script
# Requires Go ≥ 1.22, libclang-dev (Linux)
export LIBCLANG_PATH=/usr/lib/llvm-18/lib
cargo build --release --bin prove5. Run the web app
cd web
pnpm dev
# Open http://localhost:3000Local development
The monorepo uses pnpm workspaces. Packages:
| Path | Package | Description |
|---|---|---|
| packages/shared | @assay/shared | Chain constants, TypeScript types |
| packages/ingestion | @assay/ingestion | Blockscout client + fill normalizer |
| web/ | — | Next.js 15 frontend + /api/prove route |
| contracts/ | — | Foundry project (Solidity) |
| zk/program | — | SP1 guest program (Rust) |
| zk/script | — | SP1 host binaries: prove, spike, vkey |
To work on just the frontend without the ZK prover, set SP1_PROVER=mockin web/.env.local. The API route will generate a mock proof immediately.
Environment variables
| Variable | Required | Description |
|---|---|---|
| DEPLOYER_PRIVATE_KEY | Yes | EVM private key for contract deployment and claim submission |
| SP1_VERIFIER_ADDRESS | Yes | Address of deployed SP1VerifierPlonk contract |
| ASSAY_REGISTRY_ADDRESS | Yes | Address of deployed AssayRegistry contract |
| ASSAY_PROGRAM_VKEY | Yes | SP1 program verification key (output of vkey binary) |
| BLOCKSCOUT_API_KEY | Yes | Blockscout Pro API key for fill ingestion |
| SP1_PROVER | Yes | mock | cpu | network |
| NETWORK_PRIVATE_KEY | Network only | Wallet holding PROVE tokens on Succinct network |
| NETWORK_RPC_URL | Network only | https://rpc.mainnet.succinct.xyz |
| PROVER_BIN | No | Absolute path to pre-built prove binary (default: auto-detected) |
| REPO_ROOT | No | Absolute path to monorepo root (default: cwd/../) |
| NEXT_PUBLIC_ASSAY_REGISTRY_ADDRESS | Frontend | Registry address exposed to browser |
Fills & ingestion
A Fill represents a single completed trade leg on Robinhood Chain — a token transfer event emitted by the DEX router or vault contract. ASSAY ingests these via the Blockscout token-transfers API and normalizes multi-leg swaps into net token flows per wallet.
Fill type
// packages/shared/src/types.ts
export interface Fill {
txHash: string; // 0x-prefixed transaction hash
blockNumber: number; // RHC block number
timestamp: number; // Unix seconds
wallet: string; // 0x-prefixed wallet address
tokenIn: string; // contract address of input token
tokenOut: string; // contract address of output token
amountIn: bigint; // raw amount (before decimals)
amountOut: bigint; // raw amount (before decimals)
decimalsIn: number;
decimalsOut: number;
}Ingestion CLI
# Fetch and normalize fills for a wallet over a block range
npx assay-ingest \
--wallet 0xYourWallet \
--from 1200000 \
--to 1400000 \
--out fills.json \
--chain 46630Options
| Flag | Default | Description |
|---|---|---|
| --wallet | required | EVM wallet address to query |
| --from | 0 | Start block (inclusive) |
| --to | latest | End block (inclusive) |
| --out | stdout | Output file path for fills JSON array |
| --chain | 46630 | Chain ID (46630 testnet, 4663 mainnet) |
Normalization logic
Raw Blockscout token transfers are grouped by transaction hash. Within each transaction, ASSAY computes net token flow for the target wallet:
// Pseudocode (normalize.ts)
for each tx in transfers:
group events by txHash
for each tokenAddress:
net = sum(received) - sum(sent)
if net_usdc < 0 && net_stock > 0:
emit Fill(USDC→STOCK, amountIn=-net_usdc, amountOut=net_stock)
elif net_stock < 0 && net_usdc > 0:
emit Fill(STOCK→USDC, amountIn=-net_stock, amountOut=net_usdc)ZK proof pipeline
ASSAY uses SP1 v6.3.1 — Succinct's RISC-V zkVM — to generate cryptographic proofs of trading performance. The guest program is standard Rust compiled to a RISC-V target; no custom circuit language required.
Guest program inputs / outputs
| Field | Direction | Type | Description |
|---|---|---|---|
| fills | Private stdin | Vec<Fill> | Serialized trade fills (borsh/bincode) |
| wallet | Private stdin | [u8; 20] | EVM wallet address |
| from_block | Private stdin | u64 | Start block of window |
| to_block | Private stdin | u64 | End block of window |
| AssayClaim | Public output | struct | Committed claim — see below |
Proof scheme: Plonk
ASSAY uses Plonk (not Groth16) because it requires no per-circuit trusted setup. The SP1 Plonk verifier contract handles on-chain verification. Gas cost is approximately 400k — one-time per claim.
Anchoring
The fillsAnchor is the crux of ASSAY's security model. It prevents an agent from proving performance over an invented set of trades by binding the ZK proof to a specific sequence of real on-chain transactions.
// Computed inside the ZK guest program
let mut sorted = fills.clone();
sorted.sort_by_key(|f| (f.block_number, f.tx_hash));
let anchor_input: Vec<u8> = sorted
.iter()
.flat_map(|f| {
let mut b = f.tx_hash.to_vec(); // 32 bytes
b.extend(f.block_number.to_be_bytes()); // 8 bytes
b
})
.collect();
let fills_anchor = keccak256(&anchor_input); // [u8; 32]The anchor is included in the AssayClaim public output, and stored on-chain in the AssayRegistry. Anyone can re-derive it by fetching the same fills from Blockscout and hashing them identically.
AssayClaim schema
The public output of the ZK program, ABI-encoded and stored on-chain:
// Solidity ABI (AssayClaim public values layout)
struct AssayClaim {
address wallet; // trader wallet
uint64 fromBlock; // window start block
uint64 toBlock; // window end block
int32 pnlBp; // PnL in basis points (e.g. 500 = +5.00%)
uint32 maxDrawdownBp; // max drawdown in basis points
bool withinRisk; // all fills within declared risk limits
bytes32 fillsAnchor; // keccak256 of (txHash || blockNumber) list
}Rust representation
// zk/program/src/main.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssayClaim {
pub wallet: [u8; 20],
pub from_block: u64,
pub to_block: u64,
pub pnl_bp: i32,
pub max_drawdown_bp: u32,
pub within_risk: bool,
pub fills_anchor: [u8; 32],
}Smart Contracts
All contracts are compiled with Solidity 0.8.20 via Foundry. The repository is at contracts/.
AssayRegistry
Append-only registry for verified performance claims. No owner, no upgradability, no admin functions.
// SPDX-License-Identifier: MIT
interface IAssayRegistry {
struct Claim {
address wallet;
uint64 fromBlock;
uint64 toBlock;
int32 pnlBp;
uint32 maxDrawdownBp;
bool withinRisk;
bytes32 fillsAnchor;
uint256 timestamp;
}
/// Submit a ZK-verified performance claim.
/// Reverts if the SP1 proof is invalid.
function submitClaim(
bytes calldata proof,
bytes calldata publicValues
) external;
/// Return the number of claims for a wallet.
function claimCount(address wallet) external view returns (uint256);
/// Return a specific claim by index.
function getClaim(address wallet, uint256 index)
external view returns (Claim memory);
/// Return all claims for a wallet.
function getClaims(address wallet)
external view returns (Claim[] memory);
event ClaimSubmitted(
address indexed wallet,
int32 pnlBp,
bytes32 fillsAnchor,
uint256 index
);
}Verification flow
submitClaim calls ISP1Verifier.verifyProof(vkey, publicValues, proofBytes)internally. If the call reverts, the transaction reverts. A claim is only written to storage if the Plonk proof is valid against the registered program vkey.
SP1VerifierPlonk
Standard SP1 verifier contract from Succinct Labs, version 6.1.0, Plonk variant. Source: @sp1-contracts/v6.1.0/SP1VerifierPlonk.sol. No modifications.
interface ISP1Verifier {
function verifyProof(
bytes32 programVKey,
bytes calldata publicValues,
bytes calldata proofBytes
) external view;
}Deployed addresses
Testnet (chainId 46630):
ASSAY_REGISTRY_ADDRESS in .envASSAY_PROGRAM_VKEY in .envAPI Reference
POST /api/prove
Orchestrates the full proof pipeline: ingest fills → generate ZK proof → submit on-chain. This is a server-side Next.js route handler running on the VPS.
Request
POST /api/prove
Content-Type: application/json
{
"wallet": "0xYourWalletAddress",
"fromBlock": 1200000,
"toBlock": 1400000
}Response — success
HTTP 200
{
"ok": true,
"txHash": "0xabc...def",
"claimIndex": 0,
"claim": {
"wallet": "0x...",
"fromBlock": 1200000,
"toBlock": 1400000,
"pnlBp": 312,
"maxDrawdownBp": 87,
"withinRisk": true,
"fillsAnchor": "0x..."
}
}Response — error
HTTP 400 / 500
{
"ok": false,
"error": "No fills found for wallet in block range"
}Pipeline internals
// web/app/api/prove/route.ts (simplified)
export async function POST(req: Request) {
const { wallet, fromBlock, toBlock } = await req.json();
// 1. Ingest fills from Blockscout
const fills = await fetchFills(wallet, fromBlock, toBlock, chainId);
if (!fills.length) throw new Error("No fills found");
// 2. Write fills.json to tmp
const fillsPath = writeTmp(fills);
// 3. Run pre-built 'prove' binary
const outPath = tmpFile('proof.json');
await run(PROVER_BIN, ['--input', fillsPath, '--out', outPath], REPO_ROOT);
// 4. Read proof output
const { proofBytes, publicValues } = JSON.parse(readFile(outPath));
// 5. Submit on-chain via viem
const txHash = await submitClaim(proofBytes, publicValues);
return Response.json({ ok: true, txHash, ... });
}Ingestion CLI
The @assay/ingestion package ships a CLI that can be used independently of the web app to produce fills.json files for any wallet.
# Install globally
npm install -g @assay/ingestion
# Fetch fills
assay-ingest \
--wallet 0xABCDEF... \
--from 0 \
--to latest \
--out ./fills.json
# Output format
[
{
"txHash": "0x...",
"blockNumber": 1234567,
"timestamp": 1700000000,
"wallet": "0x...",
"tokenIn": "0x...",
"tokenOut": "0x...",
"amountIn": "1000000",
"amountOut": "997345",
"decimalsIn": 6,
"decimalsOut": 6
},
...
]Blockscout adapter
Located at packages/ingestion/src/blockscout.ts. Uses the Etherscan-compatible v1 API on robinhoodchain.blockscout.com.
// packages/ingestion/src/blockscout.ts
export async function fetchTokenTransfers(
wallet: string,
startBlock: number,
endBlock: number,
apiKey?: string
): Promise<RawTransfer[]>
// Base URL
const BASE = 'https://robinhoodchain.blockscout.com/api/v1'
// Endpoint used
GET /api?module=account&action=tokentx
&address={wallet}
&startblock={from}
&endblock={to}
&sort=asc
&apikey={BLOCKSCOUT_API_KEY}AI Agent Integration
ASSAY is designed to be called by autonomous AI trading agents that want to publish verifiable track records. The minimal integration is a single HTTP call to /api/prove after a trading session ends.
Minimal integration
// TypeScript agent snippet
async function publishProof(wallet: string, fromBlock: number, toBlock: number) {
const response = await fetch('https://assay.yourapp.com/api/prove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wallet, fromBlock, toBlock }),
});
const result = await response.json();
if (!result.ok) throw new Error(result.error);
console.log("Proof submitted: " + result.txHash);
console.log("PnL: " + (result.claim.pnlBp / 100) + "%");
return result;
}Python agent snippet
import requests
def publish_proof(wallet: str, from_block: int, to_block: int):
resp = requests.post(
"https://assay.yourapp.com/api/prove",
json={"wallet": wallet, "fromBlock": from_block, "toBlock": to_block},
timeout=600, # proof takes 3-10 minutes
)
resp.raise_for_status()
data = resp.json()
print(f"Proof submitted: {data['txHash']}")
return dataRead claims without the API
// Read directly from AssayRegistry via viem
import { createPublicClient, http } from 'viem';
const client = createPublicClient({
chain: robinhoodChainTestnet, // chainId: 46630
transport: http('https://testnet.rpc.robinhood.com'),
});
const claims = await client.readContract({
address: ASSAY_REGISTRY_ADDRESS,
abi: assayRegistryAbi,
functionName: 'getClaims',
args: [walletAddress],
});
// claims: AssayClaim[]
// claims[i].pnlBp / 100 = PnL percentageMCP tool specification
ASSAY exposes two MCP (Model Context Protocol) tools for AI assistants and agent frameworks that support the MCP standard.
Tool: assay_prove
{
"name": "assay_prove",
"description": "Generate and publish a ZK proof of trading performance for a wallet on Robinhood Chain. Takes 3-10 minutes. Returns transaction hash and claim details.",
"inputSchema": {
"type": "object",
"properties": {
"wallet": { "type": "string", "description": "EVM wallet address (0x...)" },
"fromBlock": { "type": "integer", "description": "Start block of the trading window" },
"toBlock": { "type": "integer", "description": "End block. Use 0 for latest." }
},
"required": ["wallet", "fromBlock"]
}
}Tool: assay_leaderboard
{
"name": "assay_leaderboard",
"description": "Query the AssayRegistry for verified performance claims. Returns a list of claims sorted by PnL.",
"inputSchema": {
"type": "object",
"properties": {
"wallet": {
"type": "string",
"description": "Optional. Filter by wallet address."
},
"minPnlBp": {
"type": "integer",
"description": "Optional. Minimum PnL in basis points (e.g. 100 = 1%)."
},
"limit": {
"type": "integer",
"description": "Max results to return. Default 20."
}
}
}
}Leaderboard query
The leaderboard is fully on-chain. You can query it using any EVM JSON-RPC client or via the AssayRegistry ABI without going through ASSAY's server.
// Fetch top performers via Blockscout event logs
// Listen for ClaimSubmitted(indexed wallet, pnlBp, fillsAnchor, index)
const logs = await client.getLogs({
address: ASSAY_REGISTRY_ADDRESS,
event: parseAbiItem(
'event ClaimSubmitted(address indexed wallet, int32 pnlBp, bytes32 fillsAnchor, uint256 index)'
),
fromBlock: 0n,
toBlock: 'latest',
});
// Sort by pnlBp descending
const leaderboard = logs
.map(log => ({
wallet: log.args.wallet,
pnlBp: log.args.pnlBp,
anchor: log.args.fillsAnchor,
}))
.sort((a, b) => Number(b.pnlBp) - Number(a.pnlBp));Succinct network prover
In production, ASSAY uses Succinct's hosted prover network instead of running SP1 locally. This reduces proof generation from ~3 hours (CPU) to ~5 minutes.
Requirements
- A wallet with PROVE tokens (on Ethereum mainnet or BNB Chain)
- Deposit PROVE tokens to the Succinct network via network.succinct.xyz
- Set
SP1_PROVER=networkandNETWORK_PRIVATE_KEYin env
Configuration
# .env
SP1_PROVER=network
NETWORK_PRIVATE_KEY=0xYourPrivateKey # holds PROVE tokens
NETWORK_RPC_URL=https://rpc.mainnet.succinct.xyzsubmitClaim transaction gas only.VPS deployment
The production setup runs on a Linux VPS with nginx as reverse proxy.
Process
# 1. Build the prove binary (do this on the VPS, needs Go + libclang)
export LIBCLANG_PATH=/usr/lib/llvm-18/lib
export PATH="$PATH:/usr/local/go/bin"
cd /root/assay/zk/script
cargo build --release --bin prove
# 2. Build Next.js
cd /root/assay/web
pnpm build
# 3. Start with PM2 (or systemd)
pm2 start "pnpm start" --name assay-web --cwd /root/assay/web
# 4. Nginx config fragment
server {
listen 80;
server_name your-domain.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_read_timeout 900s; # proof can take up to 15 min
}
}/api/prove route is synchronous and holds the HTTP connection open until the proof is submitted on-chain.Security notes
- Private key exposure —
DEPLOYER_PRIVATE_KEYandNETWORK_PRIVATE_KEYare loaded by the Next.js API route. They are never exposed to the browser. Use a minimal-balance wallet for the deployer. - Registry immutability — once a claim is submitted, it cannot be deleted. The registry has no owner or upgrade mechanism.
- Replay protection — the
fillsAnchoruniquely identifies a proof. Submitting the same proof twice will succeed as a second claim entry (it does not overwrite). UseclaimCount(wallet)to detect duplicates before submitting. - fillsAnchor verification — any third party can verify a claim by fetching the same block range from Blockscout, hashing the fills identically, and comparing against the stored
fillsAnchor. - SP1 program vkey — the
ASSAY_PROGRAM_VKEYis baked into the AssayRegistry at deployment time. Upgrading the ZK program requires redeploying the registry with a new vkey.