kcolbchain lab. zero-knowledge ML inference

Tutorial progress

1

What is ZKML

Zero-Knowledge Machine Learning (ZKML) lets you prove that a specific ML model produced a specific output for a specific input -- without revealing the model weights. The proof is a compact cryptographic object that anyone can verify, including a smart contract on Ethereum.

Use cases for ZKML:

2

EZKL vs Giza: two approaches

The two leading ZKML frameworks take different approaches:

EZKL

  • Proof system: Halo2 (KZG or IPA)
  • Input: ONNX model file
  • Strengths: Broad ONNX operator coverage, fast proving for small models, Solidity verifier generation
  • Proving time: ~10s for a small classifier, minutes for medium models
  • Verifier gas: ~300K-500K gas on Ethereum
  • Best for: Smaller models (< 10M params), quick prototyping

Giza

  • Proof system: STARK (Cairo/Stone)
  • Input: ONNX model via Orion framework
  • Strengths: No trusted setup, transparent proofs, Starknet-native verification
  • Proving time: Slower but scales better to larger models
  • Verifier gas: Lower on Starknet, higher on L1
  • Best for: Production ZKML, larger models, Starknet ecosystem

This tutorial covers both. We start with EZKL (easier to get running) and show the Giza alternative in step 7.

3

Prerequisites

Before you start

  • Python 3.10+ with torch and onnx
  • EZKL CLI: pip install ezkl (or cargo install ezkl for the Rust binary)
  • Foundry for deploying the Solidity verifier
  • A Sepolia testnet wallet with test ETH
  • Optional: pip install giza-agents for the Giza path
4

Train and export a model

We will use a small neural network that predicts whether a DeFi lending position is at risk of liquidation. The model takes 4 features (collateral ratio, borrow APY, price volatility, utilization rate) and outputs a risk score between 0 and 1.

train_model.py
import torch
import torch.nn as nn
import numpy as np

# Simple 2-layer network for liquidation risk prediction
class LiquidationRiskModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(4, 16),
            nn.ReLU(),
            nn.Linear(16, 8),
            nn.ReLU(),
            nn.Linear(8, 1),
            nn.Sigmoid(),
        )

    def forward(self, x):
        return self.net(x)

# Generate synthetic training data
np.random.seed(42)
N = 2000
collateral_ratio = np.random.uniform(1.0, 3.0, N)
borrow_apy = np.random.uniform(0.01, 0.30, N)
volatility = np.random.uniform(0.05, 0.80, N)
utilization = np.random.uniform(0.3, 0.99, N)

# Label: high risk if collateral ratio is low AND volatility is high
risk = ((collateral_ratio < 1.5) & (volatility > 0.4)).astype(np.float32)

X = np.column_stack([collateral_ratio, borrow_apy, volatility, utilization]).astype(np.float32)
y = risk.reshape(-1, 1)

X_t, y_t = torch.tensor(X), torch.tensor(y)

# Train
model = LiquidationRiskModel()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.BCELoss()

for epoch in range(100):
    pred = model(X_t)
    loss = loss_fn(pred, y_t)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if epoch % 25 == 0:
        print(f"Epoch {epoch}, loss: {loss.item():.4f}")

# Export to ONNX (required by EZKL)
dummy_input = torch.randn(1, 4)
torch.onnx.export(
    model, dummy_input, "liquidation_risk.onnx",
    input_names=["features"],
    output_names=["risk_score"],
    dynamic_axes={"features": {0: "batch"}},
)
print("Exported to liquidation_risk.onnx")

# Save a test input for proving
test_input = torch.tensor([[1.2, 0.15, 0.65, 0.85]])  # high risk case
with torch.no_grad():
    test_output = model(test_input)
print(f"Test prediction: {test_output.item():.4f} (should be close to 1.0 = high risk)")

# Save test input as JSON for EZKL
import json
json.dump({"input_data": [test_input.tolist()]}, open("input.json", "w"))
5

Generate a ZK proof with EZKL

EZKL converts the ONNX model into an arithmetic circuit, then generates a zero-knowledge proof that the model produced the claimed output. The pipeline has four steps: calibrate, compile, setup, prove.

terminal -- EZKL pipeline
# Step 1: Generate settings (calibrate the circuit)
ezkl gen-settings \
  --model liquidation_risk.onnx \
  --settings settings.json

# Step 2: Calibrate with sample data for optimal quantization
ezkl calibrate-settings \
  --model liquidation_risk.onnx \
  --settings settings.json \
  --data input.json

# Step 3: Compile the model to a circuit
ezkl compile-circuit \
  --model liquidation_risk.onnx \
  --compiled-circuit model.compiled \
  --settings settings.json

# Step 4: Run the trusted setup (generates proving + verification keys)
ezkl setup \
  --compiled-circuit model.compiled \
  --pk-path pk.key \
  --vk-path vk.key

# Step 5: Generate the proof
ezkl prove \
  --compiled-circuit model.compiled \
  --pk-path pk.key \
  --witness input.json \
  --proof-path proof.json

# Step 6: Verify locally (sanity check before on-chain)
ezkl verify \
  --proof-path proof.json \
  --vk-path vk.key \
  --settings settings.json

echo "Proof verified locally!"

What just happened:

Proof size: For this small model, the proof is approximately 1-2 KB. Verification on Ethereum costs ~300K-500K gas (~$0.15-0.25 at typical L1 gas prices). On L2s like Base or Arbitrum, verification costs under $0.01.

6

Deploy the on-chain verifier

EZKL can generate a Solidity verifier contract from the verification key. This contract has a single function: verify(bytes proof, uint256[] instances) that returns true if the proof is valid.

terminal -- generate and deploy verifier
# Generate the Solidity verifier contract
ezkl create-evm-verifier \
  --vk-path vk.key \
  --settings settings.json \
  --sol-code-path Verifier.sol

# The generated Verifier.sol contains the verification key
# baked into the contract -- no external dependencies.

# Deploy with Foundry
forge create Verifier.sol:Halo2Verifier \
  --rpc-url https://sepolia.base.org \
  --private-key $PRIVATE_KEY

# => Deployer: 0x...your_address...
# => Deployed to: 0x...verifier_address...

Now write a consumer contract that uses the verifier to gate on-chain actions based on ML predictions:

LiquidationOracle.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IVerifier {
    function verify(
        bytes calldata proof,
        uint256[] calldata instances
    ) external view returns (bool);
}

contract LiquidationOracle {
    IVerifier public immutable verifier;

    // Stores the latest verified risk prediction
    struct RiskPrediction {
        uint256 riskScore;     // fixed-point, scaled by 1e18
        uint256 timestamp;
        address submitter;
    }

    mapping(address => RiskPrediction) public predictions;

    event RiskUpdated(address indexed position, uint256 riskScore, address submitter);

    constructor(address _verifier) {
        verifier = IVerifier(_verifier);
    }

    /// @notice Submit a verified ML risk prediction
    /// @param position The lending position address
    /// @param proof The EZKL proof bytes
    /// @param instances Public inputs/outputs of the circuit
    function submitPrediction(
        address position,
        bytes calldata proof,
        uint256[] calldata instances
    ) external {
        // Verify the ZK proof on-chain
        require(verifier.verify(proof, instances), "Invalid ZKML proof");

        // instances[0..3] = input features (collateral ratio, etc.)
        // instances[4] = model output (risk score)
        uint256 riskScore = instances[4];

        predictions[position] = RiskPrediction({
            riskScore: riskScore,
            timestamp: block.timestamp,
            submitter: msg.sender
        });

        emit RiskUpdated(position, riskScore, msg.sender);
    }

    /// @notice Check if a position is high risk (score > 0.7)
    function isHighRisk(address position) external view returns (bool) {
        RiskPrediction memory pred = predictions[position];
        require(pred.timestamp > 0, "No prediction");
        require(block.timestamp - pred.timestamp < 1 hours, "Prediction stale");
        return pred.riskScore > 7e17; // 0.7 scaled by 1e18
    }
}

A DeFi lending protocol can now call isHighRisk(position) to check whether a ZKML-verified model predicts a position is at risk. The proof guarantees the prediction came from the correct model -- no one can submit a fake prediction.

7

Alternative: Giza + STARK proofs

If you prefer STARK proofs (no trusted setup, transparent), Giza takes a different path. Giza uses the Orion framework to represent ML models as Cairo programs, then generates STARK proofs using the Stone prover.

giza_pipeline.py
from giza_agents import GizaAgent, AgentConfig
from giza_agents.model import GizaModel

# Step 1: Transpile ONNX model to Cairo via Orion
config = AgentConfig(
    model_path="liquidation_risk.onnx",
    framework="CAIRO",
)

model = GizaModel(config)
model.transpile()  # converts ONNX -> Cairo/Orion representation

# Step 2: Create a verifiable agent
agent = GizaAgent(
    model=model,
    chain="starknet-sepolia",  # STARK verification is native on Starknet
)

# Step 3: Run inference with proof generation
input_data = [1.2, 0.15, 0.65, 0.85]
result = agent.predict(input_data, verify=True)

print(f"Prediction: {result.output}")
print(f"Proof ID: {result.proof_id}")
print(f"Verifiable on-chain: {result.verification_url}")

# Step 4: Verify on Starknet
verification = agent.verify_on_chain(result.proof_id)
print(f"On-chain verification tx: {verification.tx_hash}")

Key differences from the EZKL path:

8

Next steps

You can now generate zero-knowledge proofs of ML inference and verify them on-chain. Here is where to go next: